From 2f12fe8cda3c52b2a33a496964173c906f8e196a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B9=89=E4=BB=91?= Date: Wed, 24 Jun 2026 09:47:23 +0800 Subject: [PATCH] feat: add topology graph v0.2.2 contract --- README.md | 13 +- .../2026-06-17-markitdown-reference-notes.md | 17 + package-lock.json | 59 ++ package.json | 1 + schemas/topology-graph.schema.json | 552 ++++++++++++++++++ schemas/topology-graph.schema.md | 17 +- src/cli.ts | 9 +- src/engine.ts | 134 ++++- src/locator.ts | 94 ++- src/metadata.ts | 15 +- src/summary.ts | 28 + src/types.ts | 22 +- tests/integration.test.ts | 47 +- tests/locator.test.ts | 39 ++ tests/metadata.test.ts | 15 + tests/schema.test.ts | 21 + 16 files changed, 1041 insertions(+), 42 deletions(-) create mode 100644 docs/source/2026-06-17-markitdown-reference-notes.md create mode 100644 schemas/topology-graph.schema.json create mode 100644 src/summary.ts create mode 100644 tests/schema.test.ts diff --git a/README.md b/README.md index e173573..ac33b6e 100644 --- a/README.md +++ b/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. - 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. +- 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. - 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 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 @@ -52,6 +54,12 @@ Entry-state low-risk exploration: 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: ```bash @@ -60,7 +68,7 @@ npm run build ## 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: @@ -72,6 +80,7 @@ Key objects: - `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. - `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 diff --git a/docs/source/2026-06-17-markitdown-reference-notes.md b/docs/source/2026-06-17-markitdown-reference-notes.md new file mode 100644 index 0000000..6374645 --- /dev/null +++ b/docs/source/2026-06-17-markitdown-reference-notes.md @@ -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. diff --git a/package-lock.json b/package-lock.json index fe0441f..c2aa091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@types/node": "^22.15.30", + "ajv": "^8.20.0", "tsx": "^4.20.3", "typescript": "^5.8.3" } @@ -471,6 +472,23 @@ "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": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -513,6 +531,30 @@ "@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": { "version": "2.3.2", "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_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": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", @@ -557,6 +606,16 @@ "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": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", diff --git a/package.json b/package.json index b87d772..59ea32b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@types/node": "^22.15.30", + "ajv": "^8.20.0", "tsx": "^4.20.3", "typescript": "^5.8.3" } diff --git a/schemas/topology-graph.schema.json b/schemas/topology-graph.schema.json new file mode 100644 index 0000000..616f5dc --- /dev/null +++ b/schemas/topology-graph.schema.json @@ -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 } + } + } +} diff --git a/schemas/topology-graph.schema.md b/schemas/topology-graph.schema.md index af21108..683cf89 100644 --- a/schemas/topology-graph.schema.md +++ b/schemas/topology-graph.schema.md @@ -1,6 +1,6 @@ # 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` @@ -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. - `skippedActions`: state-local default-explorer actions that risk policy refused to execute. - `failedObservations`: failed locator, actionability, action, or effect observations recorded as evidence. +- `summary`: machine-readable artifact counts and grouped counts. ## Evidence Levels @@ -64,11 +65,12 @@ This document describes the JSON shape emitted by the engine. It is a practical - `locatorId`: locator candidate id. - `elementId`: parent element id. This must equal the containing `ActionableElement.elementId`. - `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. - `expression`: human-readable locator expression. - `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. - `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. - `createdAt`: ISO timestamp for edge capture. - 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` @@ -103,8 +106,16 @@ This document describes the JSON shape emitted by the engine. It is a practical ## `FailedObservation` - `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. - `message`: diagnostic message. - `evidence`: observed facts explaining the failure. - `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`. + +## `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. diff --git a/src/cli.ts b/src/cli.ts index f273eb7..fcc7790 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,6 +33,12 @@ function parseArgs(argv: string[]): ParsedArgs { case "--max-actions": options.maxActions = Number(requireValue(argv, ++index, "--max-actions")); 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": options.headed = true; break; @@ -54,7 +60,7 @@ function requireValue(argv: string[], index: number, flag: string): string { function usage(): never { console.error(`Usage: action-topology scan --url [--out graph.json] [--headed] [--storage-state state.json] - action-topology explore --url [--out graph.json] [--max-actions 10] [--headed] [--storage-state state.json] + action-topology explore --url [--out graph.json] [--max-actions 10] [--navigation-timeout-ms 30000] [--action-timeout-ms 2000] [--headed] [--storage-state state.json] `); process.exit(1); } @@ -63,4 +69,3 @@ main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : String(error)); process.exit(1); }); - diff --git a/src/engine.ts b/src/engine.ts index 2ce8453..61a939a 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,6 +8,7 @@ import { resolveLocator } from "./locator.js"; import { buildEmptyGraph, defaultViewport } from "./metadata.js"; import { defaultExploreDecision } from "./risk.js"; import { snapshotPage } from "./snapshot.js"; +import { refreshGraphSummary } from "./summary.js"; import type { ActionableElement, ActionEdge, FailedObservation, LocatorCandidate, SnapshotResult, TopologyGraph } from "./types.js"; export interface EngineOptions { @@ -16,6 +17,8 @@ export interface EngineOptions { headed?: boolean; storageState?: string; maxActions?: number; + navigationTimeoutMs?: number; + actionTimeoutMs?: number; } interface ExploreCandidate { @@ -26,22 +29,40 @@ interface ExploreCandidate { export async function scanUrl(options: EngineOptions): Promise { const browser = await chromium.launch({ headless: !options.headed }); try { + const requestedEntryUrl = toNavigableUrl(options.url); 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 = buildEmptyGraph({ - inputUrl: options.url, - entryUrl: page.url(), - mode: "scan", - storageState: options.storageState, - }); - graph.states.push(snapshot.state); - graph.elements.push(...snapshot.elements); - await maybeWriteGraph(graph, options.out); - await context.close(); - return graph; + try { + const page = await createReplayPage(context, requestedEntryUrl, options); + const snapshot = await snapshotPage(page); + const graph = buildEmptyGraph({ + inputUrl: options.url, + entryUrl: page.url(), + mode: "scan", + storageState: options.storageState, + }); + graph.states.push(snapshot.state); + graph.elements.push(...snapshot.elements); + await maybeWriteGraph(graph, options.out); + return graph; + } catch (error) { + 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 { await browser.close(); } @@ -55,9 +76,25 @@ export async function exploreUrl(options: EngineOptions): Promise let entrySnapshot: SnapshotResult; let entryUrl: string; try { - const page = await createReplayPage(entryContext, requestedEntryUrl); + const page = await createReplayPage(entryContext, requestedEntryUrl, options); entrySnapshot = await snapshotPage(page); 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 { await entryContext.close(); } @@ -96,7 +133,19 @@ export async function exploreUrl(options: EngineOptions): Promise const decision = defaultExploreDecision(element, "click"); const actionContext = await newContext(browser, options); 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 beforeSnapshot = await snapshotPage(actionPage); const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`; @@ -108,14 +157,14 @@ export async function exploreUrl(options: EngineOptions): Promise } try { - await target.click({ timeout: 2000, trial: true }); - await target.click({ timeout: 2000 }); + await target.click({ timeout: actionTimeout(options), trial: true }); + await target.click({ timeout: actionTimeout(options) }); } catch (error) { recordClickFailure(graph, beforeSnapshot, element, error); 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 discoveredState = addStateIfNew(graph, afterSnapshot.state); graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId"); @@ -162,10 +211,10 @@ async function newContext(browser: Browser, options: EngineOptions): Promise { +async function createReplayPage(context: BrowserContext, entryUrl: string, options: EngineOptions): Promise { const page = await context.newPage(); - await page.goto(entryUrl, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle").catch(() => undefined); + await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: navigationTimeout(options) }); + await page.waitForLoadState("networkidle", { timeout: actionTimeout(options) }).catch(() => undefined); return page; } @@ -243,6 +292,7 @@ function recordFailedObservation(graph: TopologyGraph, observation: FailedObserv } async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise { + refreshGraphSummary(graph); if (!out) { console.log(JSON.stringify(graph, null, 2)); return; @@ -251,6 +301,44 @@ async function maybeWriteGraph(graph: TopologyGraph, out?: string): Promise> = []; @@ -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) { candidates.push({ framework: "playwright", @@ -258,19 +277,44 @@ export async function verifyLocators(page: Page, raw: RawActionableElement, loca } 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) { 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": - return page.getByLabel(descriptor.label); + return root.getByLabel(descriptor.label); case "placeholder": - return page.getByPlaceholder(descriptor.placeholder); + return root.getByPlaceholder(descriptor.placeholder); case "text": - return page.getByText(descriptor.text, { exact: descriptor.exact }); + return root.getByText(descriptor.text, { exact: descriptor.exact }); case "testId": - return page.getByTestId(descriptor.testId); + return root.getByTestId(descriptor.testId); 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 })); } +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 { const cleaned = value?.replace(/\s+/g, " ").trim(); return cleaned || undefined; diff --git a/src/metadata.ts b/src/metadata.ts index ba73668..3be1ebf 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js"; import { shortHash } from "./hash.js"; +import { summarizeGraph } from "./summary.js"; const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const; @@ -33,7 +34,7 @@ export function buildEmptyGraph(args: { "normalization-v0.2", "locator-scoring-v0.2", ])}`; - return { + const graph: TopologyGraph = { schemaVersion: SCHEMA_VERSION, metadata: { artifactVersion: "topology-graph-v0.2", @@ -59,7 +60,19 @@ export function buildEmptyGraph(args: { edges: [], skippedActions: [], 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 { diff --git a/src/summary.ts b/src/summary.ts new file mode 100644 index 0000000..19e412b --- /dev/null +++ b/src/summary.ts @@ -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(items: T[], keyFor: (item: T) => string): Record { + const counts: Record = {}; + for (const item of items) { + const key = keyFor(item); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} diff --git a/src/types.ts b/src/types.ts index 69a05a7..fb2ab24 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ 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 EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external"; @@ -73,7 +73,7 @@ export interface RuntimeContext { authContext: "anonymous" | "storage_state"; } -export type LocatorDescriptor = +export type BaseLocatorDescriptor = | { kind: "role"; role: string; name?: string } | { kind: "label"; label: string } | { kind: "placeholder"; placeholder: string } @@ -81,6 +81,10 @@ export type LocatorDescriptor = | { kind: "testId"; testId: string } | { kind: "css"; selector: string }; +export type LocatorDescriptor = + | BaseLocatorDescriptor + | { kind: "scoped"; scope: LocatorScopeDescriptor; target: BaseLocatorDescriptor }; + export type LocatorFramework = "playwright" | "css"; export interface LocatorScopeDescriptor { @@ -229,6 +233,18 @@ export interface TopologyGraph { edges: ActionEdge[]; skippedActions: SkippedAction[]; failedObservations: FailedObservation[]; + summary: GraphSummary; +} + +export interface GraphSummary { + states: number; + elements: number; + edges: number; + skippedActions: number; + failedObservations: number; + failedObservationsByReason: Partial>; + skippedActionsByRiskCategory: Partial>; + edgesByRiskCategory: Partial>; } export interface SkippedAction { @@ -245,7 +261,7 @@ export interface SkippedAction { export interface FailedObservation { failedObservationId: string; - stateId: string; + stateId: string | null; elementId: string | null; actionType: ActionType | null; reason: diff --git a/tests/integration.test.ts b/tests/integration.test.ts index b93c1e0..b89fde0 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -21,6 +22,20 @@ async function withTempOutput(name: string, callback: (out: string) => Promis } } +async function withServer(handler: Parameters[0], callback: (url: string) => Promise): Promise { + const server = createServer(handler); + await new Promise((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((resolve, reject) => { + (server as Server).close((error) => (error ? reject(error) : resolve())); + }); + } +} + function hasEdgeForAccessibleName( graph: { 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 () => { const graph = await withTempOutput("metadata", (out) => scanUrl({ url: fixturePath, out })); 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.viewport.width, 1440); 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) { + if (failure.stateId === null) continue; assert.equal(stateIds.has(failure.stateId), true); if (failure.elementId === null) continue; 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(""); + 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 () => { 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); diff --git a/tests/locator.test.ts b/tests/locator.test.ts index d398dbd..4b15f21 100644 --- a/tests/locator.test.ts +++ b/tests/locator.test.ts @@ -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); }); +test("scoped row locator resolves duplicated row actions to one target", async () => { + await withPage( + ` + + + +
Acme Co
Beta LLC
+ `, + 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 () => { await withPage(``, async (page) => { const rawElement = raw({ diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index df4089c..708d81f 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -22,6 +22,21 @@ test("crawl session id is unique for each generated graph", () => { 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(callback: () => T): T { const RealDate = Date; const fixedTime = new RealDate("2026-06-16T00:00:00.000Z").getTime(); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..631a2f4 --- /dev/null +++ b/tests/schema.test.ts @@ -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 }); + } +});