diff --git a/.github/actions/python-package/action.yml b/.github/actions/python-package/action.yml index 9b2cd77b..0ea73373 100644 --- a/.github/actions/python-package/action.yml +++ b/.github/actions/python-package/action.yml @@ -28,7 +28,7 @@ runs: working-directory: ${{ inputs.working-directory }} run: | python scripts/gen_contract.py - git diff --exit-code src/selenium_devtools/_contract.py + git diff --exit-code src/selenium_devtools/_contract.py src/selenium_devtools/_wire_types.py - name: ๐Ÿงช Unit tests shell: bash diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 501daef2..b9cfcdbc 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,16 +2,18 @@ name: Python Adapter # Triggered by packages/shared/src/** as well, because the adapter's wire # contract is GENERATED from it: scope names, the runner id, the collector route, -# the worker query keys and the run-id env var. A rename there leaves the -# committed `_contract.py` stale, and stale means the adapter keeps sending a name -# nothing reads โ€” a silent loss, not an error. +# the worker query keys, the run-id env var, and every payload type, through +# `wire-schema.json` (which vitest keeps in step with shared's src). A change +# there leaves `_contract.py` or `_wire_types.py` stale, and stale means the +# adapter keeps sending a name or a shape nothing reads โ€” a silent loss, not an +# error. # # This was previously excluded to keep a Python job off every PR that touches # shared. The objection was that a red Python job would block work unrelated to -# this adapter; it does not hold, because the only thing a shared change can turn -# red here is the drift check, and drift IS related โ€” it means that PR broke this -# contract. The tests do not read shared at all. Scoped to `src/` so a version -# bump or a README edit under shared does not trigger it. +# this adapter; it does not hold, because the only things a shared change can +# turn red here are the drift check and the tests' wire-schema validation, and +# both mean that PR broke this contract. Scoped to `src/` and the schema so a +# version bump or a README edit under shared does not trigger it. on: push: branches: @@ -19,6 +21,7 @@ on: paths: - packages/selenium-devtools-py/** - packages/shared/src/** + - packages/shared/wire-schema.json - .github/workflows/python.yml - .github/workflows/python-release.yml - .github/actions/python-package/** @@ -26,6 +29,7 @@ on: paths: - packages/selenium-devtools-py/** - packages/shared/src/** + - packages/shared/wire-schema.json - .github/workflows/python.yml - .github/workflows/python-release.yml - .github/actions/python-package/** diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ed91928c..6cee2380 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -124,7 +124,7 @@ Imports from: `core`, `shared`, `selenium-webdriver` (peer). Does not import: ot ### `packages/selenium-devtools-py` โ€” Python Selenium adapter -The same contract from another language. Not a port of the JavaScript adapters: it shares no code with them, only the `{scope, data}` wire format, so nothing here imports `core` or `shared` โ€” the parts of those it needs are **generated** into `src/selenium_devtools/_contract.py` by `scripts/gen_contract.py`, which doubles as a drift guard. +The same contract from another language. Not a port of the JavaScript adapters: it shares no code with them, only the `{scope, data}` wire format, so nothing here imports `core` or `shared` โ€” the parts of those it needs are **generated** by `scripts/gen_contract.py`, which doubles as a drift guard: constants and scope names into `src/selenium_devtools/_contract.py`, and payload TypedDicts into `_wire_types.py` from `packages/shared/wire-schema.json`. That schema is itself generated from shared's TypeScript types by `packages/shared/scripts/wire-schema.ts`, one JSON Schema per wire scope, so a language that cannot import shared still reads one source. Contains: diff --git a/packages/app/test-ui/shell/fixtures.ts b/packages/app/test-ui/shell/fixtures.ts index 440f8c70..3e2c9046 100644 --- a/packages/app/test-ui/shell/fixtures.ts +++ b/packages/app/test-ui/shell/fixtures.ts @@ -146,7 +146,8 @@ export function traceLog(overrides: Partial = {}): TraceLog { metadata: testrunnerMetadata, commands: loginCommands, sources: {}, - suites: loginRun.frame, + // A fragment stands in for the full SuiteStats: the app reads only these fields. + suites: loginRun.frame as TraceLog['suites'], ...overrides } } diff --git a/packages/app/tests/data-manager.test.ts b/packages/app/tests/data-manager.test.ts index 7db7bc8a..585dfba2 100644 --- a/packages/app/tests/data-manager.test.ts +++ b/packages/app/tests/data-manager.test.ts @@ -237,7 +237,8 @@ function traceLog(overrides: Partial = {}): TraceLog { metadata: { sessionId: SESSION, type: TraceType.Testrunner }, commands: [command()], sources: { '/specs/login.e2e.ts': 'await browser.url(url)' }, - suites: suitesFrame(suite('login-suite')), + // A fragment stands in for the full SuiteStats: the app reads only these fields. + suites: suitesFrame(suite('login-suite')) as TraceLog['suites'], ...overrides } } diff --git a/packages/selenium-devtools-py/README.md b/packages/selenium-devtools-py/README.md index 44ee8efe..da1ba538 100644 --- a/packages/selenium-devtools-py/README.md +++ b/packages/selenium-devtools-py/README.md @@ -426,8 +426,9 @@ backend this run reports to โ€” and for trace mode, which opens none at all. src/selenium_devtools/ __init__.py public API โ€” enable() / disable() / get_capturer() constants.py defaults, env-var names, skip sets, pinned backend version - types.py TypedDicts for the wire payloads (mirror packages/shared) + types.py re-exports the wire payload TypedDicts, plus JSONValue/Scope _contract.py GENERATED from packages/shared โ€” scope names + CONTRACT_VERSION + _wire_types.py GENERATED from packages/shared/wire-schema.json โ€” payload TypedDicts utils.py framework-agnostic helpers (now_ms, iso, to_jsonable, call_source) frames.py pure builders for each {scope,data} payload transport.py stdlib WebSocket client (handshake, masked frames, ping/pong, control reader) @@ -455,8 +456,9 @@ src/selenium_devtools/ lifecycle.py dashboard window open/close + shutdown-on-disconnect rerun.py launch/rerun commands the dashboard's run controls spawn pytest_plugin.py CLI/ini config surface + suite/test tree feeder (opt-in) -scripts/gen_contract.py regenerate _contract.py from shared (dev-time; also a drift-guard) -tests/ stdlib-unittest unit tests (no selenium/pytest needed) +scripts/gen_contract.py regenerate _contract.py + _wire_types.py from shared (dev-time; also a drift-guard) +tests/ stdlib-unittest unit tests (no selenium/pytest needed); every + sent frame is validated against shared's wire schema e2e_check.py real-Chrome smoke (plain script) e2e/test_smoke.py real-Chrome smoke (pytest + plugin) (example lives at repo root: examples/selenium-py/scripts/web_form.py) @@ -471,7 +473,7 @@ coupling is handled explicitly rather than via a `workspace:^`-style resolver: |---|---|---| | **Adapter** (this package) | `pip install -e` | PyPI: `pip install selenium-devtools-py` | | **Backend + UI** (Node) | `node packages/backend/dist/server.js` | npm: `npx @wdio/devtools-backend@` | -| **Wire contract** (`shared`) | regenerated into `_contract.py` | the generated `_contract.py` ships in the wheel | +| **Wire contract** (`shared`) | regenerated into `_contract.py` + `_wire_types.py` | both generated files ship in the wheel | `enable()` obtains the backend in this order (local vs published falls out of it): @@ -486,11 +488,17 @@ no auto-resolution, so it's bumped deliberately alongside a contract change. Regenerate the contract after any change to `packages/shared`: ```bash +pnpm --filter @wdio/devtools-shared gen:wire-schema # TS types โ†’ wire-schema.json python3 packages/selenium-devtools-py/scripts/gen_contract.py ``` -It fails loudly if a scope the adapter needs disappeared from `shared` โ€” a -build-time drift alarm. +The first step reads shared's payload types with the TypeScript compiler and +writes one JSON Schema per wire scope; vitest fails when the committed copy is +stale. The second turns it into TypedDicts and fails loudly if a scope the +adapter needs disappeared from `shared`. The unit tests then validate every +frame the adapter sends against that schema, so a field shared does not +declare, a missing required one, or a null where shared expects absence all +fail the build. ## Test @@ -509,7 +517,7 @@ Two workflows, mirroring the JS split (`ci.yml` tests / `release.yml` publish): - **`python.yml`** โ€” runs on PRs + pushes touching this package or `shared`: unit tests on Python 3.10 + 3.13, a contract-drift check (regenerate - `_contract.py`, fail on any diff), and a build + `twine check --strict` so a + `_contract.py` and `_wire_types.py`, fail on any diff), and a build + `twine check --strict` so a packaging mistake surfaces on the PR rather than under the publish button. Zero repo config needed. - **`python-release.yml`** โ€” **manual** (`workflow_dispatch`, like the JS @@ -580,6 +588,7 @@ above. What the JavaScript adapters have and this one does not: - **Capture never breaks tests.** Commands are recorded around the real call; errors are captured *and re-raised* unchanged; a missing dashboard is a no-op. - **Contract drift** is the main long-term risk (see the integration artifact). - Mitigated two ways: `_contract.py` is generated from `packages/shared` (scope - names + `CONTRACT_VERSION`), and the generator fails if a required scope - vanishes. Full field-level type generation is a future step. + Mitigated three ways: `_contract.py` is generated from `packages/shared` (scope + names + `CONTRACT_VERSION`), the generator fails if a required scope + vanishes, and the payload types are generated field by field from + `wire-schema.json`, which the unit tests validate every sent frame against. diff --git a/packages/selenium-devtools-py/changes/generated-wire-types.md b/packages/selenium-devtools-py/changes/generated-wire-types.md new file mode 100644 index 00000000..2643a208 --- /dev/null +++ b/packages/selenium-devtools-py/changes/generated-wire-types.md @@ -0,0 +1,11 @@ +--- +minor +--- + +Frames now leave out an optional field instead of sending it as null: +`callSource` on commands and tests, `url` on session metadata, `status` and +`endTime` on a network request still in flight, and `state` on an unfinished +suite. A native session's viewport now carries `offsetLeft: 0`, `offsetTop: 0` +and `scale: 1`, as the JavaScript adapters send it. The payload types in +`selenium_devtools.types` are now generated from the dashboard's own schema; +`ElementScripts` is renamed to `ElementScriptsResponse`. diff --git a/packages/selenium-devtools-py/scripts/gen_contract.py b/packages/selenium-devtools-py/scripts/gen_contract.py index 82b243b5..4a396036 100644 --- a/packages/selenium-devtools-py/scripts/gen_contract.py +++ b/packages/selenium-devtools-py/scripts/gen_contract.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import keyword import re import sys from pathlib import Path @@ -200,6 +201,98 @@ def _native_platforms(device_ts: str) -> list[str]: return re.findall(r"'([^']+)'", m.group(1)) +class _TypedDictWriter: + """Renders ``wire-schema.json``'s ``$defs`` as TypedDicts. + + Required and optional keys are split across a base class and a + ``total=False`` subclass because ``typing.Required`` needs Python 3.11 and + the package supports 3.10. An inline object becomes a class named after its + owner and key, since a TypedDict field cannot hold an anonymous shape. + """ + + def __init__(self, defs: dict) -> None: + self._defs = defs + self._classes: dict[str, str] = {} + + def render(self) -> str: + for name, schema in self._defs.items(): + self._class(name, schema) + names = sorted(self._classes) + lines = [ + "# GENERATED by scripts/gen_contract.py from packages/shared/wire-schema.json.", + "# Do not edit by hand โ€” run the script to regenerate.", + "from __future__ import annotations", + "", + "from typing import Any, Dict, List, Literal, Optional, TypedDict, Union", + "", + f"__all__ = {names!r}", + ] + for name in names: + lines += ["", "", self._classes[name]] + return "\n".join(lines) + "\n" + + def _class(self, name: str, schema: dict) -> None: + if name in self._classes: + raise SystemExit(f"wire schema: two shapes both want the name {name}") + self._classes[name] = "" + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + fields = {} + for key, prop in props.items(): + if not key.isidentifier() or keyword.iskeyword(key): + raise SystemExit(f"wire schema: {name}.{key} is not a Python identifier") + fields[key] = self._type(prop, name + key[:1].upper() + key[1:]) + req = [f" {k}: {t}" for k, t in fields.items() if k in required] + opt = [f" {k}: {t}" for k, t in fields.items() if k not in required] + if req and opt: + body = ( + f"class _{name}Required(TypedDict):\n" + "\n".join(req) + "\n\n\n" + f"class {name}(_{name}Required, total=False):\n" + "\n".join(opt) + ) + elif req: + body = f"class {name}(TypedDict):\n" + "\n".join(req) + else: + body = f"class {name}(TypedDict, total=False):\n" + ("\n".join(opt) or " pass") + self._classes[name] = body + + def _type(self, schema: dict, inline_name: str) -> str: + if "$ref" in schema: + return schema["$ref"].rsplit("/", 1)[-1] + if "anyOf" in schema: + parts = [self._type(s, inline_name) for s in schema["anyOf"]] + rest = [p for p in parts if p != "None"] + inner = rest[0] if len(rest) == 1 else f"Union[{', '.join(rest)}]" + return f"Optional[{inner}]" if len(rest) < len(parts) else inner + if "enum" in schema: + return f"Literal[{', '.join(repr(v) for v in schema['enum'])}]" + if "const" in schema: + return f"Literal[{schema['const']!r}]" + kind = schema.get("type") + if kind is None: + return "Any" + if kind == "array": + return f"List[{self._type(schema['items'], inline_name)}]" + if kind == "object": + if "properties" in schema: + self._class(inline_name, schema) + return inline_name + extra = schema.get("additionalProperties") + value = self._type(extra, inline_name) if isinstance(extra, dict) else "Any" + return f"Dict[str, {value}]" + primitives = {"string": "str", "number": "float", "boolean": "bool", "null": "None"} + if kind not in primitives: + raise SystemExit(f"wire schema: unsupported type {kind!r} for {inline_name}") + return primitives[kind] + + +def _wire_types(schema_path: Path) -> str: + if not schema_path.exists(): + raise SystemExit( + f"{schema_path} is missing: run `pnpm --filter @wdio/devtools-shared gen:wire-schema`" + ) + return _TypedDictWriter(json.loads(schema_path.read_text())["$defs"]).render() + + def main() -> int: root = _repo_root() shared = root / "packages" / "shared" @@ -320,10 +413,14 @@ def main() -> int: f'ENV_LAUNCH_COMMAND = "{reuse_env["LAUNCH_COMMAND"]}"', "", ] - out = shared.parent / "selenium-devtools-py" / "src" / "selenium_devtools" / "_contract.py" + pkg = shared.parent / "selenium-devtools-py" / "src" / "selenium_devtools" + out = pkg / "_contract.py" out.write_text("\n".join(lines)) print(f"wrote {out.relative_to(root)} (contract v{version}, " f"{len(data_keys)} data scopes, {len(control)} control scopes)") + wire = pkg / "_wire_types.py" + wire.write_text(_wire_types(shared / "wire-schema.json")) + print(f"wrote {wire.relative_to(root)}") return 0 diff --git a/packages/selenium-devtools-py/src/selenium_devtools/_wire_types.py b/packages/selenium-devtools-py/src/selenium_devtools/_wire_types.py new file mode 100644 index 00000000..437a393e --- /dev/null +++ b/packages/selenium-devtools-py/src/selenium_devtools/_wire_types.py @@ -0,0 +1,397 @@ +# GENERATED by scripts/gen_contract.py from packages/shared/wire-schema.json. +# Do not edit by hand โ€” run the script to regenerate. +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional, TypedDict, Union + +__all__ = ['AccessibilityNode', 'ActionSnapshot', 'BaselineClearedWsPayload', 'BaselineSavedWsPayload', 'ClearExecutionDataWsPayload', 'CommandLog', 'CommandLogPoint', 'ConsoleLog', 'DeviceInfo', 'DocumentInfo', 'DocumentInfoDocumentInfo', 'DocumentInfoHeaders', 'ElementScriptsResponse', 'Metadata', 'NetworkRequest', 'NetworkRequestResponse', 'PerformanceData', 'PerformanceDataNavigation', 'PerformanceDataNavigationTiming', 'PerformanceDataResources', 'PreservedAttempt', 'PreservedAttemptTest', 'PreservedAttemptWindow', 'PreservedStep', 'ReplaceCommandWsPayload', 'ScreencastFrame', 'ScreencastInfo', 'SerializedError', 'SuiteStats', 'TestError', 'TestErrorMatcherResult', 'TestStats', 'TraceExportRequest', 'TraceExportResult', 'TraceMutation', 'Viewport'] + + +class _AccessibilityNodeRequired(TypedDict): + role: str + name: str + selector: str + depth: float + level: Union[str, float] + disabled: str + checked: str + expanded: str + selected: str + pressed: str + required: str + readonly: str + + +class AccessibilityNode(_AccessibilityNodeRequired, total=False): + isInViewport: bool + + +class _ActionSnapshotRequired(TypedDict): + timestamp: float + command: str + + +class ActionSnapshot(_ActionSnapshotRequired, total=False): + url: str + title: str + screenshot: str + elements: List[Any] + snapshotText: str + accessibilityTree: List[AccessibilityNode] + + +class BaselineClearedWsPayload(TypedDict): + testUid: str + + +class BaselineSavedWsPayload(TypedDict): + testUid: str + attempt: PreservedAttempt + + +class ClearExecutionDataWsPayload(TypedDict, total=False): + uid: str + entryType: Literal['test', 'suite'] + clearSuiteTree: bool + runStart: bool + + +class _CommandLogRequired(TypedDict): + command: str + args: List[Any] + timestamp: float + + +class CommandLog(_CommandLogRequired, total=False): + title: str + result: Any + error: SerializedError + startTime: float + sequence: float + callSource: str + screenshot: str + testUid: str + performance: PerformanceData + cookies: str + documentInfo: DocumentInfo + id: float + stepUid: str + snapshotText: str + point: CommandLogPoint + selector: str + + +class CommandLogPoint(TypedDict): + x: float + y: float + + +class _ConsoleLogRequired(TypedDict): + type: Literal['trace', 'debug', 'log', 'info', 'warn', 'error'] + args: List[Any] + timestamp: float + + +class ConsoleLog(_ConsoleLogRequired, total=False): + source: Literal['browser', 'test', 'terminal'] + + +class _DeviceInfoRequired(TypedDict): + platform: Literal['android', 'ios'] + + +class DeviceInfo(_DeviceInfoRequired, total=False): + name: str + version: str + + +class DocumentInfo(TypedDict): + url: str + title: str + headers: DocumentInfoHeaders + documentInfo: DocumentInfoDocumentInfo + + +class DocumentInfoDocumentInfo(TypedDict): + readyState: str + referrer: str + characterSet: str + + +class DocumentInfoHeaders(TypedDict): + userAgent: str + language: str + platform: str + + +class ElementScriptsResponse(TypedDict): + accessibilityTree: str + elements: str + + +class _MetadataRequired(TypedDict): + type: Literal['standalone', 'testrunner'] + + +class Metadata(_MetadataRequired, total=False): + url: str + options: Any + capabilities: Any + viewport: Viewport + sessionId: str + testEnv: str + host: str + modulePath: str + desiredCapabilities: Dict[str, Any] + runner: Literal['mocha', 'jasmine', 'cucumber', 'nightwatch', 'nightwatch-cucumber', 'selenium-webdriver'] + device: DeviceInfo + + +class _NetworkRequestRequired(TypedDict): + id: str + url: str + method: str + timestamp: float + startTime: float + type: Literal['document', 'stylesheet', 'script', 'image', 'font', 'fetch', 'xhr', 'other'] + + +class NetworkRequest(_NetworkRequestRequired, total=False): + headers: Dict[str, str] + cookies: List[Any] + status: float + statusText: str + endTime: float + time: float + initiator: str + requestHeaders: Dict[str, str] + responseHeaders: Dict[str, str] + navigation: str + redirectChain: List[Any] + children: List[NetworkRequest] + response: NetworkRequestResponse + error: str + requestBody: str + responseBody: str + size: float + + +class NetworkRequestResponse(TypedDict): + fromCache: bool + headers: Dict[str, str] + mimeType: str + status: float + + +class PerformanceData(TypedDict, total=False): + navigation: PerformanceDataNavigation + resources: List[PerformanceDataResources] + + +class PerformanceDataNavigation(TypedDict): + url: str + timing: PerformanceDataNavigationTiming + + +class PerformanceDataNavigationTiming(TypedDict, total=False): + loadTime: float + domReady: float + responseTime: float + dnsLookup: float + tcpConnection: float + serverResponse: float + + +class PerformanceDataResources(TypedDict): + url: str + duration: float + size: float + type: str + startTime: float + responseEnd: float + + +class _PreservedAttemptRequired(TypedDict): + testUid: str + scope: Literal['test', 'suite'] + capturedAt: float + window: PreservedAttemptWindow + test: PreservedAttemptTest + commands: List[CommandLog] + consoleLogs: List[ConsoleLog] + networkRequests: List[NetworkRequest] + mutations: List[Any] + sources: Dict[str, str] + + +class PreservedAttempt(_PreservedAttemptRequired, total=False): + steps: List[PreservedStep] + + +class PreservedAttemptTest(TypedDict, total=False): + title: str + fullTitle: str + file: str + callSource: str + start: float + end: float + duration: float + state: Literal['passed', 'failed', 'skipped', 'pending', 'running'] + error: TestError + + +class PreservedAttemptWindow(TypedDict): + start: float + end: float + + +class _PreservedStepRequired(TypedDict): + uid: str + + +class PreservedStep(_PreservedStepRequired, total=False): + title: str + fullTitle: str + start: float + end: float + state: Literal['passed', 'failed', 'skipped', 'pending', 'running'] + error: TestError + + +class ReplaceCommandWsPayload(TypedDict): + oldTimestamp: float + command: CommandLog + + +class ScreencastFrame(TypedDict): + data: str + timestamp: float + + +class ScreencastInfo(TypedDict, total=False): + sessionId: str + videoPath: str + videoFile: str + frameCount: float + duration: float + startTime: float + + +class _SerializedErrorRequired(TypedDict): + name: str + message: str + + +class SerializedError(_SerializedErrorRequired, total=False): + stack: str + + +class _SuiteStatsRequired(TypedDict): + uid: str + cid: str + title: str + fullTitle: str + type: Literal['suite'] + file: str + start: str + tests: List[Union[str, TestStats]] + suites: List[SuiteStats] + hooks: List[Any] + _duration: float + + +class SuiteStats(_SuiteStatsRequired, total=False): + state: Literal['passed', 'failed', 'skipped', 'pending', 'running'] + end: Optional[str] + parent: str + callSource: str + order: float + featureFile: str + + +class TestError(TypedDict, total=False): + message: str + name: str + stack: str + expected: Any + actual: Any + matcherResult: TestErrorMatcherResult + + +class TestErrorMatcherResult(TypedDict, total=False): + expected: Any + actual: Any + message: str + + +class _TestStatsRequired(TypedDict): + uid: str + cid: str + title: str + fullTitle: str + parent: str + state: Literal['passed', 'failed', 'skipped', 'pending', 'running'] + start: str + end: Optional[str] + type: Literal['test'] + file: str + retries: float + _duration: float + + +class TestStats(_TestStatsRequired, total=False): + error: SerializedError + hooks: List[Any] + callSource: str + order: float + + +class _TraceExportRequestRequired(TypedDict): + requestId: str + outputDir: str + sessionId: str + + +class TraceExportRequest(_TraceExportRequestRequired, total=False): + format: Literal['zip', 'ndjson-directory'] + fileStem: str + tracePolicy: Literal['on', 'retain-on-failure', 'retain-on-first-failure', 'on-first-retry', 'on-all-retries', 'retain-on-failure-and-retries'] + traceGranularity: Literal['test', 'session'] + + +class _TraceExportResultRequired(TypedDict): + requestId: str + + +class TraceExportResult(_TraceExportResultRequired, total=False): + path: str + paths: List[str] + error: str + declinedByPolicy: bool + + +class _TraceMutationRequired(TypedDict): + type: Literal['attributes', 'characterData', 'childList'] + addedNodes: List[Any] + removedNodes: List[str] + timestamp: float + + +class TraceMutation(_TraceMutationRequired, total=False): + attributeName: str + attributeNamespace: str + attributeValue: str + newTextContent: str + oldValue: str + target: str + previousSibling: str + nextSibling: str + url: str + + +class Viewport(TypedDict): + width: float + height: float + offsetLeft: float + offsetTop: float + scale: float diff --git a/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py b/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py index 6be520db..386296b9 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/element_scripts.py @@ -30,7 +30,7 @@ from ._contract import ELEMENT_SCRIPTS_PATH, RUNNER_ID from .constants import ELEMENT_SCRIPTS_FETCH_TIMEOUT_S, LOGGER_NAME -from .types import ElementScripts +from .types import ElementScriptsResponse _log = logging.getLogger(f"{LOGGER_NAME}.elements") @@ -55,7 +55,7 @@ def scripts_url(host: str, port: int) -> str: return f"http://{url_host}:{port}{ELEMENT_SCRIPTS_PATH}?{query}" -def fetch(host: str, port: int) -> Optional[ElementScripts]: +def fetch(host: str, port: int) -> Optional[ElementScriptsResponse]: """``{"accessibilityTree": str, "elements": str}`` from the backend, or None. None on anything at all โ€” no route (a backend older than this feature), a @@ -66,7 +66,7 @@ def fetch(host: str, port: int) -> Optional[ElementScripts]: if _cache["settled"] and _cache["origin"] == origin: return _cache["scripts"] - scripts: Optional[ElementScripts] = None + scripts: Optional[ElementScriptsResponse] = None try: with urllib.request.urlopen( # noqa: S310 โ€” a loopback backend we launched scripts_url(host, port), timeout=ELEMENT_SCRIPTS_FETCH_TIMEOUT_S diff --git a/packages/selenium-devtools-py/src/selenium_devtools/frames.py b/packages/selenium-devtools-py/src/selenium_devtools/frames.py index c0f2cd65..aa6716b1 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/frames.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/frames.py @@ -1,9 +1,8 @@ """Builders for the wire frames the dashboard renders. -Each function returns the ``data`` payload for a ``{scope, data}`` frame. -Shapes mirror ``packages/shared/src/types.ts``. Keeping them here โ€” pure and -side-effect free โ€” makes them unit-testable and the single place the contract -lives on the Python side. +Each function returns the ``data`` payload for a ``{scope, data}`` frame, +typed by the TypedDicts generated from shared's wire schema. Optional fields +are omitted rather than sent as null, because shared types them that way. """ from __future__ import annotations @@ -36,7 +35,6 @@ def metadata( entry: Metadata = { "type": "testrunner", # TraceType.Testrunner "sessionId": session_id, - "url": url, "capabilities": caps, "desiredCapabilities": caps, "testEnv": "python-selenium", @@ -54,6 +52,8 @@ def metadata( # a better frame than a zero-sized one, and the app treats absent as unknown. if viewport: entry["viewport"] = dict(viewport) + if url is not None: + entry["url"] = url return entry @@ -77,9 +77,12 @@ def command_log( "result": result, "timestamp": timestamp, "startTime": start_time, - "callSource": call_source, "id": command_id, } + # Shared types every optional field as absent-or-set, never null; the + # generated wire schema rejects a null here. + if call_source is not None: + entry["callSource"] = call_source if selector: entry["selector"] = selector # What the exporter groups a trace by: absent, `buildGroupPath` returns an @@ -122,14 +125,16 @@ def network_request( "id": request_id, "url": url, "method": method, - "status": status, "timestamp": timestamp, "startTime": start_time, - "endTime": end_time, "type": request_type, } # Response-phase fields are absent on the initial request frame; omit rather # than send nulls so the dashboard's "pending" state renders correctly. + if status is not None: + entry["status"] = status + if end_time is not None: + entry["endTime"] = end_time if status_text is not None: entry["statusText"] = status_text if time is not None: @@ -190,8 +195,9 @@ def test_stats( "file": file, "retries": 0, "_duration": max(0, end_ms - start_ms), - "callSource": call_source, } + if call_source is not None: + entry["callSource"] = call_source # Only stamped when the runner's execution order differs from the tree's # default (a suite's own tests, then its nested suites) โ€” see shared's # `order`. Absent keeps the frame identical to before. @@ -220,12 +226,13 @@ def suite_stats( "file": file, "start": iso(start_ms), "end": iso(end_ms) if end_ms is not None else None, - "state": state, "tests": tests, "suites": [], "hooks": [], "_duration": max(0, (end_ms - start_ms)) if end_ms is not None else 0, } + if state is not None: + entry["state"] = state if order is not None: entry["order"] = order return entry diff --git a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py index e9400a2c..27926813 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py @@ -43,7 +43,7 @@ start_snapshot_capture, ) from .sources import read_source -from .types import ActionSnapshot, ElementScripts, Viewport +from .types import ActionSnapshot, ElementScriptsResponse, Viewport from .utils import call_source, now_ms # Operational logging โ€” surfaced in the dashboard Console (the 'runner' stream). @@ -349,7 +349,7 @@ def action_snapshots() -> List[ActionSnapshot]: return list(_state["action_snapshots"]) -def set_element_scripts(scripts: Optional[ElementScripts]) -> None: +def set_element_scripts(scripts: Optional[ElementScriptsResponse]) -> None: """Hand over the page-side scripts fetched from the backend. Without them `_capture_action_snapshot` is a no-op, which is the state on a backend too old to serve them.""" @@ -611,7 +611,9 @@ def _driver_window(driver: Any) -> Optional[Viewport]: return None if width <= 0 or height <= 0: return None - return {"width": width, "height": height} + # A native app is never scrolled or pinch-zoomed, so these are its true + # values, as core's `fromWindow` sends them. + return {"width": width, "height": height, "offsetLeft": 0, "offsetTop": 0, "scale": 1} def _viewport(driver: Any) -> Optional[Viewport]: diff --git a/packages/selenium-devtools-py/src/selenium_devtools/types.py b/packages/selenium-devtools-py/src/selenium_devtools/types.py index 8a00620f..e00b6b77 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/types.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/types.py @@ -1,13 +1,16 @@ -"""Wire payload types โ€” the Python mirror of ``packages/shared/src/types.ts``. +"""Wire payload types. -TypedDicts document the ``data`` payload shapes behind each ``{scope, data}`` -frame the dashboard consumes. They're structural (plain dicts at runtime); the -value is a single typed definition per concept, checkable by mypy. +Every payload shape is generated into ``_wire_types`` from +``packages/shared/wire-schema.json`` and re-exported here. The two aliases below +are Python-only and have no counterpart in shared. """ from __future__ import annotations -from typing import Any, Dict, List, Optional, TypedDict, Union +from typing import Dict, List, Union + +from ._wire_types import * # noqa: F401,F403 +from ._wire_types import __all__ as _wire_names #: Anything that survives ``json.dumps``. Payloads must reduce to this. JSONValue = Union[ @@ -17,135 +20,4 @@ #: A ``{scope, data}`` frame's scope โ€” a value from the generated ``_contract``. Scope = str - -class SerializedError(TypedDict): - name: str - message: str - - -class CommandLog(TypedDict, total=False): - command: str - args: List[Any] - result: Any - error: SerializedError - timestamp: int - startTime: int - callSource: Optional[str] - id: int - screenshot: str - selector: str - testUid: str - - -class Viewport(TypedDict, total=False): - """Mirrors shared's `Viewport`. The offsets and scale are absent on a native - session, which has no page to be scrolled or pinch-zoomed.""" - - width: int - height: int - offsetLeft: float - offsetTop: float - scale: float - - -class ElementScripts(TypedDict): - """Body of the backend's element-scripts route: two injectable expressions.""" - - accessibilityTree: str - elements: str - - -class ActionSnapshot(TypedDict, total=False): - """One action's view of the page. `accessibilityTree` ships raw because the - serializer is TypeScript โ€” the backend turns it into the A11y tab's text.""" - - timestamp: int - command: str - screenshot: str - elements: List[Any] - accessibilityTree: List[Any] - - -class ConsoleLog(TypedDict): - type: str - args: List[Any] - timestamp: int - source: str - - -class NetworkRequest(TypedDict, total=False): - id: str - url: str - method: str - status: Optional[int] - statusText: str - timestamp: int - startTime: int - endTime: Optional[int] - time: int - size: int - type: str - requestHeaders: Dict[str, str] - responseHeaders: Dict[str, str] - - -class ScreencastFrame(TypedDict): - #: Base64-encoded image (PNG in polling mode). No data URI prefix. - data: str - #: Capture time, epoch milliseconds. - timestamp: int - - -class ScreencastInfo(TypedDict, total=False): - sessionId: str - videoPath: str - videoFile: str - frameCount: int - duration: int - startTime: int - - -class Metadata(TypedDict, total=False): - type: str - sessionId: str - url: Optional[str] - capabilities: Dict[str, Any] - desiredCapabilities: Dict[str, Any] - testEnv: str - runner: str - options: Dict[str, Any] - viewport: Viewport - - -class TestStats(TypedDict, total=False): - uid: str - cid: str - title: str - fullTitle: str - parent: str - state: str - start: str - end: str - type: str - file: str - retries: int - _duration: int - callSource: Optional[str] - order: int - - -class SuiteStats(TypedDict, total=False): - uid: str - cid: str - title: str - fullTitle: str - type: str - file: str - start: str - end: Optional[str] - state: Optional[str] - tests: List[TestStats] - suites: List["SuiteStats"] - hooks: List[Any] - _duration: int - order: int +__all__ = [*_wire_names, "JSONValue", "Scope"] diff --git a/packages/selenium-devtools-py/tests/test_a11y_elements.py b/packages/selenium-devtools-py/tests/test_a11y_elements.py index 83bd1295..ad0a9e47 100644 --- a/packages/selenium-devtools-py/tests/test_a11y_elements.py +++ b/packages/selenium-devtools-py/tests/test_a11y_elements.py @@ -18,21 +18,9 @@ ) from selenium_devtools.constants import ACTION_SNAPSHOT_BATCH -SCRIPTS = {"accessibilityTree": "(function(){})", "elements": "(function(){})"} - - -class FakeTransport: - def __init__(self, *, sends=True): - self.connected = True - self.sent = [] - self._sends = sends +from wire_contract import RecordingTransport - def send_json(self, scope, data): - self.sent.append((scope, data)) - return self._sends - - def close(self): - self.connected = False +SCRIPTS = {"accessibilityTree": "(function(){})", "elements": "(function(){})"} class TestFetchingTheScripts(unittest.TestCase): @@ -232,14 +220,14 @@ def test_an_empty_or_failed_read_records_no_snapshot(self): class TestStreamingTheSnapshots(unittest.TestCase): def test_they_go_out_under_the_action_snapshots_scope(self): - tx = FakeTransport() + tx = RecordingTransport() snaps = [{"timestamp": i, "command": "click"} for i in range(3)] self.assertEqual(trace_export.send_action_snapshots(tx, snaps), 3) self.assertEqual(tx.sent[0][0], SCOPE_ACTION_SNAPSHOTS) def test_a_long_run_is_batched(self): - tx = FakeTransport() + tx = RecordingTransport() total = ACTION_SNAPSHOT_BATCH * 2 + 3 snaps = [{"timestamp": i, "command": "click"} for i in range(total)] @@ -247,9 +235,10 @@ def test_a_long_run_is_batched(self): self.assertEqual(len(tx.sent), 3) def test_a_refused_socket_stops_without_raising(self): - tx = FakeTransport(sends=False) - self.assertEqual(trace_export.send_action_snapshots(tx, [{"a": 1}]), 0) - self.assertEqual(trace_export.send_action_snapshots(None, [{"a": 1}]), 0) + snapshot = {"timestamp": 1, "command": "click"} + tx = RecordingTransport(sends=False) + self.assertEqual(trace_export.send_action_snapshots(tx, [snapshot]), 0) + self.assertEqual(trace_export.send_action_snapshots(None, [snapshot]), 0) if __name__ == "__main__": diff --git a/packages/selenium-devtools-py/tests/test_bidi.py b/packages/selenium-devtools-py/tests/test_bidi.py index 86610a98..1729f0dc 100644 --- a/packages/selenium-devtools-py/tests/test_bidi.py +++ b/packages/selenium-devtools-py/tests/test_bidi.py @@ -6,19 +6,7 @@ from selenium_devtools import bidi from selenium_devtools.capturer import SessionCapturer - -class FakeTransport: - connected = True - - def __init__(self): - self.sent = [] - - def send_json(self, scope, data): - self.sent.append((scope, data)) - return True - - def close(self): - pass +from wire_contract import RecordingTransport class FakeLogEntry: @@ -263,7 +251,7 @@ class TestAttachDefensive(unittest.TestCase): """attach must degrade โ€” never raise โ€” when BiDi isn't available.""" def _capturer(self): - return SessionCapturer(FakeTransport()) + return SessionCapturer(RecordingTransport()) def test_attach_skips_without_websocket_capability(self): class Driver: @@ -528,7 +516,7 @@ def _dispatch(network, bidi_event, params): def test_raw_params_reach_the_handlers_and_are_captured(self): module = fake_network_module() network = module.Network() - capturer = SessionCapturer(FakeTransport()) + capturer = SessionCapturer(RecordingTransport()) with mock.patch.dict( sys.modules, {"selenium.webdriver.common.bidi.network": module} @@ -568,7 +556,7 @@ def test_both_events_are_subscribed_and_counted(self): sys.modules, {"selenium.webdriver.common.bidi.network": module} ): bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()) ) manager = network._event_manager @@ -602,7 +590,7 @@ def test_nothing_another_subscriber_reads_is_written(self): ): self.assertTrue( bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()) ) ) @@ -624,7 +612,7 @@ def test_a_concurrent_subscriber_still_gets_selenium_deserializer(self): sys.modules, {"selenium.webdriver.common.bidi.network": module} ): bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()) ) # Selenium's own registration, for an event the adapter also holds. network.add_event_handler("before_request_sent", seen.append) @@ -651,7 +639,7 @@ def test_a_half_subscribed_pair_captures_nothing(self): """ module = fake_network_module() network = module.Network() - capturer = SessionCapturer(FakeTransport()) + capturer = SessionCapturer(RecordingTransport()) real_subscribe = network._event_manager.subscribe_to_event @@ -713,7 +701,7 @@ def failing_subscribe(bidi_event, contexts=None): ): with self.assertLogs("selenium_devtools.bidi", level="WARNING"): attached = bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()) ) self.assertFalse(attached) @@ -747,7 +735,7 @@ def failing_subscribe(bidi_event, contexts=None): ): with self.assertLogs("selenium_devtools.bidi", level="WARNING"): bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()) ) self.assertIn("network.beforeRequestSent", manager.subscribed) @@ -766,7 +754,7 @@ def test_a_selenium_without_the_event_api_is_reported_not_silent(self): with self.assertLogs("selenium_devtools.bidi", level="WARNING") as logs: # `.network` is never reached: the check happens before it. attached = bidi._attach_network( - NewSeleniumDriver(None, []), SessionCapturer(FakeTransport()) + NewSeleniumDriver(None, []), SessionCapturer(RecordingTransport()) ) self.assertFalse(attached) @@ -819,7 +807,7 @@ def test_the_counts_come_from_real_capture(self): sys.modules, {"selenium.webdriver.common.bidi.network": module} ): bidi._attach_network( - NewSeleniumDriver(network, []), SessionCapturer(FakeTransport()), stats + NewSeleniumDriver(network, []), SessionCapturer(RecordingTransport()), stats ) for request_id in ("R1", "R2"): diff --git a/packages/selenium-devtools-py/tests/test_capturer.py b/packages/selenium-devtools-py/tests/test_capturer.py index d6fe8e9f..a9636bf7 100644 --- a/packages/selenium-devtools-py/tests/test_capturer.py +++ b/packages/selenium-devtools-py/tests/test_capturer.py @@ -1,25 +1,14 @@ import unittest +from selenium_devtools import frames from selenium_devtools.capturer import SessionCapturer - -class FakeTransport: - connected = True - - def __init__(self): - self.sent = [] - - def send_json(self, scope, data): - self.sent.append((scope, data)) - return True - - def close(self): - pass +from wire_contract import RecordingTransport class TestSessionCapturer(unittest.TestCase): def setUp(self): - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) def test_metadata_sent_once(self): @@ -51,7 +40,7 @@ def test_capture_command_records_error(self): def test_suites_sent_as_uid_keyed_records(self): # UI expects Record[], not a plain array of suites. - suite = {"uid": "suite-1", "title": "S", "tests": []} + suite = frames.suite_stats(uid="suite-1", title="S", file="s.py", start_ms=0, tests=[]) self.cap.send_suites([suite]) self.assertIn(("suites", [{"suite-1": suite}]), self.tx.sent) diff --git a/packages/selenium-devtools-py/tests/test_filmstrip.py b/packages/selenium-devtools-py/tests/test_filmstrip.py index c4aa65aa..0afe7577 100644 --- a/packages/selenium-devtools-py/tests/test_filmstrip.py +++ b/packages/selenium-devtools-py/tests/test_filmstrip.py @@ -13,22 +13,7 @@ from selenium_devtools._contract import SCOPE_SCREENCAST_FRAMES from selenium_devtools.constants import SCREENCAST_FRAME_BATCH - -class FakeTransport: - def __init__(self, *, sends=True, raises_after=None): - self.connected = True - self.sent = [] - self._sends = sends - self._raises_after = raises_after - - def send_json(self, scope, data): - if self._raises_after is not None and len(self.sent) >= self._raises_after: - raise OSError("socket gone") - self.sent.append((scope, data)) - return self._sends - - def close(self): - self.connected = False +from wire_contract import RecordingTransport def frames(n, *, start=0): @@ -37,7 +22,7 @@ def frames(n, *, start=0): class TestStreamingTheFilmstrip(unittest.TestCase): def test_frames_go_out_under_the_screencast_frames_scope(self): - tx = FakeTransport() + tx = RecordingTransport() self.assertEqual(trace_export.send_frames(tx, frames(3)), 3) @@ -48,7 +33,7 @@ def test_frames_go_out_under_the_screencast_frames_scope(self): # One message carrying a full buffer would sit near the socket's payload # limit, and the transport masks payloads in a per-byte Python loop. def test_a_large_buffer_is_batched(self): - tx = FakeTransport() + tx = RecordingTransport() total = SCREENCAST_FRAME_BATCH * 2 + 7 self.assertEqual(trace_export.send_frames(tx, frames(total)), total) @@ -62,15 +47,15 @@ def test_a_large_buffer_is_batched(self): # Fewer frames is a poorer video, not a broken trace โ€” the run must not fail. def test_a_refused_or_throwing_socket_stops_without_raising(self): - refused = FakeTransport(sends=False) + refused = RecordingTransport(sends=False) self.assertEqual(trace_export.send_frames(refused, frames(120)), 0) - throwing = FakeTransport(raises_after=1) + throwing = RecordingTransport(raises_after=1) sent = trace_export.send_frames(throwing, frames(120)) self.assertEqual(sent, SCREENCAST_FRAME_BATCH) def test_nothing_to_send_is_not_a_message(self): - tx = FakeTransport() + tx = RecordingTransport() self.assertEqual(trace_export.send_frames(tx, []), 0) self.assertEqual(trace_export.send_frames(None, frames(3)), 0) self.assertEqual(tx.sent, []) @@ -113,7 +98,7 @@ def _finalize(self, recorder, *, trace, filmstrip): {"trace": trace, "filmstrip": filmstrip, "filmstrip_frames": []}, ): instrumentation._finalize_screencast( - SessionCapturer(FakeTransport()), "sess", entry + SessionCapturer(RecordingTransport()), "sess", entry ) return instrumentation.screencast_frames() @@ -205,7 +190,7 @@ def setUp(self): from selenium_devtools.capturer import SessionCapturer instrumentation.uninstall() - self.cap = SessionCapturer(FakeTransport()) + self.cap = SessionCapturer(RecordingTransport()) def tearDown(self): instrumentation.uninstall() @@ -305,7 +290,7 @@ def tearDown(self): def _arm(self): self.pkg._active.update( - capturer=None, transport=FakeTransport(), process=None, url=None, + capturer=None, transport=RecordingTransport(), process=None, url=None, handle=None, terminal=None, logs=None, excepthook=None, trace=True, traced=False, filmstrip_mark=None, ) diff --git a/packages/selenium-devtools-py/tests/test_instrumentation.py b/packages/selenium-devtools-py/tests/test_instrumentation.py index bbbc0758..8a7eb884 100644 --- a/packages/selenium-devtools-py/tests/test_instrumentation.py +++ b/packages/selenium-devtools-py/tests/test_instrumentation.py @@ -7,19 +7,7 @@ from selenium_devtools import instrumentation, snapshot from selenium_devtools.capturer import SessionCapturer - -class FakeTransport: - connected = True - - def __init__(self): - self.sent = [] - - def send_json(self, scope, data): - self.sent.append((scope, data)) - return True - - def close(self): - pass +from wire_contract import RecordingTransport class FakeDriver: @@ -47,7 +35,7 @@ def get_screenshot_as_base64(self): class TestInstrumentation(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) instrumentation.install(self.cap, FakeDriver) self.driver = FakeDriver() @@ -147,7 +135,7 @@ class TestCommandSelector(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() instrumentation.install(SessionCapturer(self.tx), FindingDriver) self.driver = FindingDriver() self.addCleanup(instrumentation.uninstall) @@ -189,7 +177,8 @@ def execute_script(self, script, *args): self._collector_installed = True return True if "getTraceData" in script: # read (drains buffer) - return ({"mutations": [{"type": "childList", "target": "1"}]} + return ({"mutations": [{"type": "childList", "target": "1", "addedNodes": [], + "removedNodes": [], "timestamp": 1}]} if self._collector_installed else None) if "wdioTraceCollector" in script: # readiness probe return self._collector_installed @@ -211,7 +200,7 @@ def setUp(self): ) self._script_patch.start() instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) instrumentation.install(self.cap, FakeDriverWithScript) self.driver = FakeDriverWithScript() @@ -228,7 +217,7 @@ def test_navigation_flushes_mutations(self): self.driver.execute("get", {"url": "https://x/"}) muts = self._mutations() self.assertEqual(len(muts), 1) - self.assertEqual(muts[0], [{"type": "childList", "target": "1"}]) + self.assertEqual(muts[0][0]["target"], "1") # The collector was injected exactly once during setup. installs = [s for s in self.driver._script_calls if "createElement" in s] self.assertEqual(len(installs), 1) @@ -254,7 +243,7 @@ class TestDefaultSuite(unittest.TestCase): def setUp(self): instrumentation.uninstall() instrumentation.set_external_suites(False) - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) instrumentation.install(self.cap, FakeDriver) self.driver = FakeDriver() @@ -518,7 +507,7 @@ class TestMultipleSessions(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) self.attached = [] self._bidi = mock.patch.object( @@ -631,7 +620,7 @@ class TestScreencastAttributionAcrossSessions(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) self._rec = mock.patch.object( instrumentation, "ScreencastRecorder", StubRecorder @@ -687,7 +676,7 @@ class TestQuitTargetsItsOwnSession(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) self._rec = mock.patch.object( instrumentation, "ScreencastRecorder", StubRecorder @@ -727,7 +716,7 @@ class TestOwnershipFollowsTheDriver(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) self._rec = mock.patch.object( instrumentation, "ScreencastRecorder", StubRecorder @@ -785,7 +774,7 @@ class TestCaptureDoesNotOutliveItsDriver(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) self._bidi = mock.patch.object( instrumentation.bidi, "attach", new=lambda driver, capturer: False @@ -907,7 +896,12 @@ class TestNavigationRowsGetTheirTimings(unittest.TestCase): "navigation": {"url": "https://x/secure", "timing": {"loadTime": 700}}, "resources": [], "cookies": "", - "documentInfo": {"title": "Secure Area"}, + "documentInfo": { + "url": "https://x/secure", + "title": "Secure Area", + "headers": {"userAgent": "Chrome", "language": "en-US", "platform": "MacIntel"}, + "documentInfo": {"readyState": "complete", "referrer": "", "characterSet": "UTF-8"}, + }, } class Driver: @@ -933,7 +927,7 @@ def get_screenshot_as_base64(self): return "shot" def setUp(self): - self.cap = SessionCapturer(FakeTransport()) + self.cap = SessionCapturer(RecordingTransport()) instrumentation.uninstall() instrumentation.install(self.cap, self.Driver) self.addCleanup(instrumentation.uninstall) @@ -1014,7 +1008,7 @@ def execute(self, command, params=None): outer.append("command") return FakeDriver.execute(self, command, params) - self.cap = SessionCapturer(FakeTransport()) + self.cap = SessionCapturer(RecordingTransport()) instrumentation.install(self.cap, OrderDriver) self.driver = OrderDriver() self.push = self.Push(self.order) @@ -1067,7 +1061,7 @@ class TestSessionSetupIssuesNoUserCommands(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() self.cap = SessionCapturer(self.tx) def tearDown(self): @@ -1130,7 +1124,7 @@ class TestViewportMetadata(unittest.TestCase): def setUp(self): instrumentation.uninstall() - self.tx = FakeTransport() + self.tx = RecordingTransport() instrumentation.install(SessionCapturer(self.tx), ViewportDriver) self.addCleanup(instrumentation.uninstall) @@ -1164,7 +1158,7 @@ def test_the_probe_does_not_become_a_command_row(self): def test_a_driver_that_cannot_answer_omits_it(self): instrumentation.uninstall() - tx = FakeTransport() + tx = RecordingTransport() instrumentation.install(SessionCapturer(tx), FakeDriver) # no execute_script driver = FakeDriver() driver.execute("newSession") # FakeDriver gets its session id here @@ -1191,13 +1185,16 @@ def get_window_size(self): return {"width": 1080, "height": 2219} instrumentation.uninstall() - tx = FakeTransport() + tx = RecordingTransport() instrumentation.install(SessionCapturer(tx), NativeDriver) driver = NativeDriver() driver.execute("get", {"url": "app://start"}) [meta] = [d for s, d in tx.sent if s == "metadata"] - self.assertEqual(meta["viewport"], {"width": 1080, "height": 2219}) + self.assertEqual( + meta["viewport"], + {"width": 1080, "height": 2219, "offsetLeft": 0, "offsetTop": 0, "scale": 1}, + ) self.assertEqual(driver.window_reads, 1) # And never asked the page, which is the round trip that can only fail. self.assertFalse(any("innerWidth" in s for s in driver.scripts)) @@ -1218,7 +1215,7 @@ def get_window_size(self): raise AssertionError("should not read the device window") instrumentation.uninstall() - tx = FakeTransport() + tx = RecordingTransport() instrumentation.install(SessionCapturer(tx), MobileWebDriver) MobileWebDriver().execute("get", {"url": "https://x/"}) @@ -1238,7 +1235,7 @@ def test_a_nonsense_size_is_refused(self): for bad in ([0, 800], [1280, -1], ["1280", 800], [1280], "1280x800"): with self.subTest(size=bad): instrumentation.uninstall() - tx = FakeTransport() + tx = RecordingTransport() instrumentation.install(SessionCapturer(tx), ViewportDriver) ViewportDriver(bad).execute("get", {"url": "https://x/"}) diff --git a/packages/selenium-devtools-py/tests/test_performance.py b/packages/selenium-devtools-py/tests/test_performance.py index a447d472..c1969614 100644 --- a/packages/selenium-devtools-py/tests/test_performance.py +++ b/packages/selenium-devtools-py/tests/test_performance.py @@ -13,37 +13,29 @@ from selenium_devtools.capturer import SessionCapturer from selenium_devtools.constants import NAVIGATION_COMMANDS +from wire_contract import RecordingTransport + FULL_PAYLOAD = { "navigation": { "url": "https://x/secure", "timing": {"loadTime": 812, "domReady": 410, "responseTime": 96}, }, "resources": [ - {"url": "https://x/app.css", "duration": 21, "size": 4096, "type": "link"}, - {"url": "https://x/app.js", "duration": 44, "size": 90112, "type": "script"}, + {"url": "https://x/app.css", "duration": 21, "size": 4096, "type": "link", + "startTime": 120, "responseEnd": 141}, + {"url": "https://x/app.js", "duration": 44, "size": 90112, "type": "script", + "startTime": 122, "responseEnd": 166}, ], "cookies": "session=abc", - "documentInfo": {"url": "https://x/secure", "title": "Secure Area"}, + "documentInfo": { + "url": "https://x/secure", + "title": "Secure Area", + "headers": {"userAgent": "Chrome", "language": "en-US", "platform": "MacIntel"}, + "documentInfo": {"readyState": "complete", "referrer": "", "characterSet": "UTF-8"}, + }, } -class FakeTransport: - connected = True - - def __init__(self): - self.sent = [] - - def send_json(self, scope, data): - self.sent.append((scope, data)) - return True - - def close(self): - pass - - def of_scope(self, scope): - return [data for s, data in self.sent if s == scope] - - class TestShapingThePayload(unittest.TestCase): def test_a_full_payload_lands_on_the_row(self): row = {"command": "get", "timestamp": 1000} @@ -133,12 +125,12 @@ def execute_script(self, script, *args): return self.payload def _capturer(self): - tx = FakeTransport() + tx = RecordingTransport() return SessionCapturer(tx), tx def test_the_enriched_row_goes_out_under_replace_command(self): cap, tx = self._capturer() - row = {"command": "get", "timestamp": 4242} + row = {"command": "get", "args": [], "timestamp": 4242} instrumentation._attach_performance( cap, self.Driver(), row, {"url": "https://x/secure"} @@ -181,7 +173,9 @@ def test_the_read_is_not_captured_as_a_user_command(self): cap, tx = self._capturer() driver = self.Driver() - instrumentation._attach_performance(cap, driver, {"timestamp": 1}, None) + instrumentation._attach_performance( + cap, driver, {"command": "get", "args": [], "timestamp": 1}, None + ) self.assertEqual(len(driver.scripts), 1) self.assertEqual(tx.of_scope("commands"), []) diff --git a/packages/selenium-devtools-py/tests/test_screencast.py b/packages/selenium-devtools-py/tests/test_screencast.py index e40cd0c3..874b3292 100644 --- a/packages/selenium-devtools-py/tests/test_screencast.py +++ b/packages/selenium-devtools-py/tests/test_screencast.py @@ -6,6 +6,8 @@ from selenium_devtools.capturer import SessionCapturer from selenium_devtools.screencast import ScreencastRecorder +from wire_contract import RecordingTransport + # 16x16 PNG โ€” a real decodable frame with even dimensions so libvpx/yuv420p can # actually encode a video (odd/1px dimensions make ffmpeg reject the stream). _PNG_FRAME = ( @@ -14,20 +16,6 @@ ) -class FakeTransport: - connected = True - - def __init__(self): - self.sent = [] - - def send_json(self, scope, data): - self.sent.append((scope, data)) - return True - - def close(self): - pass - - def _stub_shots(*values): """A screenshot fn returning each value in turn, then the last forever.""" seq = list(values) @@ -177,7 +165,7 @@ def test_finalize_skips_encode_without_ffmpeg(self): class TestCapturerSend(unittest.TestCase): def test_send_screencast_builds_scoped_frame(self): - cap = SessionCapturer(FakeTransport()) + cap = SessionCapturer(RecordingTransport()) cap.session_id = "sess-9" cap.send_screencast( video_path="/tmp/v.webm", video_file="v.webm", @@ -192,7 +180,7 @@ def test_send_screencast_builds_scoped_frame(self): self.assertEqual(info["startTime"], 42) def test_send_screencast_noop_without_session(self): - cap = SessionCapturer(FakeTransport()) + cap = SessionCapturer(RecordingTransport()) cap.send_screencast( video_path="/tmp/v.webm", video_file="v.webm", frame_count=5, duration=1000, start_time=42, @@ -203,7 +191,7 @@ def test_screencast_frame_carries_videopath_for_backend_registry(self): # The backend intercepts scope=='screencast', registers `videoPath`, and # serves that file at /api/video/:sessionId โ€” so the frame MUST carry # videoPath alongside sessionId. - cap = SessionCapturer(FakeTransport()) + cap = SessionCapturer(RecordingTransport()) cap.session_id = "sess-9" cap.send_screencast( video_path="/abs/v.webm", video_file="v.webm", @@ -231,7 +219,7 @@ def test_finalize_encodes_and_delivers_frame(self): self.assertTrue(os.path.isfile(info["video_path"])) self.assertEqual(info["frame_count"], 2) - cap = SessionCapturer(FakeTransport()) + cap = SessionCapturer(RecordingTransport()) cap.session_id = "sess-7" cap.send_screencast(**info) frame = [d for s, d in cap._tx.sent if s == "screencast"][0] diff --git a/packages/selenium-devtools-py/tests/test_trace_export.py b/packages/selenium-devtools-py/tests/test_trace_export.py index 60030684..2542f579 100644 --- a/packages/selenium-devtools-py/tests/test_trace_export.py +++ b/packages/selenium-devtools-py/tests/test_trace_export.py @@ -17,27 +17,25 @@ from selenium_devtools.constants import TRACE_RETENTION_POLICIES from selenium_devtools._contract import SCOPE_TRACE_EXPORT, SCOPE_TRACE_EXPORTED +from wire_contract import RecordingTransport -class FakeTransport: + +class FakeTransport(RecordingTransport): """Answers the export on a separate thread, as the real reader does.""" def __init__( self, *, connected=True, reply=None, sends=True, raises=False, delay=0.0 ): + super().__init__(sends=sends, raises_after=0 if raises else None) self.connected = connected - self.sent = [] self._reply = reply - self._sends = sends - self._raises = raises # A reply that lands before export() gets to wait would let a version # that never waits pass โ€” measured: removing the wait kept every test # green, because the answering thread usually won the race. self._delay = delay def send_json(self, scope, data): - if self._raises: - raise OSError("socket gone") - self.sent.append((scope, data)) + sent = super().send_json(scope, data) if self._reply is not None and self._sends: answer = dict(self._reply) answer.setdefault("requestId", data["requestId"]) @@ -47,10 +45,7 @@ def answer_later(): trace_export.on_result(answer) threading.Thread(target=answer_later, daemon=True).start() - return self._sends - - def close(self): - self.connected = False + return sent class TestRequestingAnExport(unittest.TestCase): diff --git a/packages/selenium-devtools-py/tests/test_wire_contract.py b/packages/selenium-devtools-py/tests/test_wire_contract.py new file mode 100644 index 00000000..f8143abe --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_wire_contract.py @@ -0,0 +1,80 @@ +"""The wire-contract check that every RecordingTransport applies.""" + +import unittest + +from selenium_devtools import _wire_types, frames + +from wire_contract import SCHEMA, RecordingTransport, violations + + +def _command(**overrides): + row = frames.command_log( + command="click", args=[], timestamp=2, start_time=1, + call_source=None, command_id=1, + ) + row.update(overrides) + return row + + +class TestTheCheck(unittest.TestCase): + def test_a_built_row_passes(self): + self.assertEqual(violations("commands", [_command()]), []) + + def test_a_null_where_shared_expects_absence_fails(self): + problems = violations("commands", [_command(callSource=None)]) + self.assertEqual(len(problems), 1) + self.assertIn("callSource", problems[0]) + + def test_a_field_shared_does_not_declare_fails(self): + problems = violations("commands", [_command(colour="red")]) + self.assertIn("'colour' is not a field shared declares", problems[0]) + + def test_a_missing_required_field_fails(self): + row = _command() + del row["args"] + self.assertIn("missing required 'args'", violations("commands", [row])[0]) + + def test_a_value_outside_an_enum_fails(self): + entry = frames.console_log(level="shout", args=[], timestamp=1) + self.assertIn("is not one of", violations("consoleLogs", [entry])[0]) + + def test_a_scope_shared_has_no_payload_for_fails(self): + self.assertIn("no payload type", violations("madeUp", {})[0]) + + def test_a_bad_frame_fails_the_test_even_when_the_sender_swallows_errors(self): + class Swallowing(unittest.TestCase): + def test_it(self): + tx = RecordingTransport() + try: + tx.send_json("commands", [_command(callSource=None)]) + except Exception: # noqa: BLE001 โ€” what every best-effort sender does + pass + + result = unittest.TestResult() + Swallowing("test_it").run(result) + self.assertEqual(len(result.failures), 1) + self.assertIn("callSource", result.failures[0][1]) + + def test_a_clean_frame_leaves_the_test_passing(self): + tx = RecordingTransport() + tx.send_json("commands", [_command()]) + self.assertEqual(tx.violations, []) + + +class TestGeneratedTypesMatchTheSchema(unittest.TestCase): + """`_wire_types.py` is regenerated and diffed in CI; this pins that the + generator splits required from optional keys the way the schema does.""" + + def test_every_definition_keeps_its_required_keys(self): + for name, schema in SCHEMA["$defs"].items(): + with self.subTest(name=name): + cls = getattr(_wire_types, name) + self.assertEqual(cls.__required_keys__, frozenset(schema["required"])) + self.assertEqual( + cls.__required_keys__ | cls.__optional_keys__, + frozenset(schema["properties"]), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/selenium-devtools-py/tests/wire_contract.py b/packages/selenium-devtools-py/tests/wire_contract.py new file mode 100644 index 00000000..b9c22329 --- /dev/null +++ b/packages/selenium-devtools-py/tests/wire_contract.py @@ -0,0 +1,142 @@ +"""Checks frames against ``packages/shared/wire-schema.json``. + +The generated TypedDicts document the contract but enforce nothing at runtime, +and nothing runs mypy over this package. This is what does: every test that +sends through ``RecordingTransport`` fails on a field shared does not declare, +a missing required one, or a null where shared expects the key to be absent. +""" + +from __future__ import annotations + +import inspect +import json +import unittest +from pathlib import Path +from typing import Any, List, Optional + + +def _schema_path() -> Path: + for parent in Path(__file__).resolve().parents: + candidate = parent / "packages" / "shared" / "wire-schema.json" + if candidate.exists(): + return candidate + raise RuntimeError("wire-schema.json not found; these tests run in the monorepo") + + +SCHEMA = json.loads(_schema_path().read_text()) + +_TYPES = { + "string": lambda v: isinstance(v, str), + "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), + "boolean": lambda v: isinstance(v, bool), + "null": lambda v: v is None, + "array": lambda v: isinstance(v, list), + "object": lambda v: isinstance(v, dict), +} + + +def violations(scope: str, data: Any) -> List[str]: + schema = SCHEMA["scopes"].get(scope) + if schema is None: + return [f"scope {scope!r} has no payload type in shared"] + out: List[str] = [] + _check(schema, data, scope, out) + return out + + +def _check(schema: dict, value: Any, path: str, out: List[str]) -> None: + if "$ref" in schema: + _check(SCHEMA["$defs"][schema["$ref"].rsplit("/", 1)[-1]], value, path, out) + return + if "anyOf" in schema: + if not any(not _errors(s, value, path) for s in schema["anyOf"]): + out.append(f"{path}: {value!r:.80} matches none of {schema['anyOf']}") + return + if "const" in schema: + if type(value) is not type(schema["const"]) or value != schema["const"]: + out.append(f"{path}: expected {schema['const']!r}, got {value!r:.80}") + return + kind = schema.get("type") + if kind is None: + return + if not _TYPES[kind](value): + out.append(f"{path}: expected {kind}, got {type(value).__name__} {value!r:.80}") + return + if "enum" in schema and value not in schema["enum"]: + out.append(f"{path}: {value!r} is not one of {schema['enum']}") + if kind == "array": + for i, item in enumerate(value): + _check(schema["items"], item, f"{path}[{i}]", out) + if kind == "object": + _check_object(schema, value, path, out) + + +def _check_object(schema: dict, value: dict, path: str, out: List[str]) -> None: + props = schema.get("properties", {}) + for key in schema.get("required", []): + if key not in value: + out.append(f"{path}: missing required {key!r}") + extra = schema.get("additionalProperties") + for key, item in value.items(): + if key in props: + _check(props[key], item, f"{path}.{key}", out) + elif extra is False: + out.append(f"{path}: {key!r} is not a field shared declares") + elif isinstance(extra, dict): + _check(extra, item, f"{path}.{key}", out) + + +def _errors(schema: dict, value: Any, path: str) -> List[str]: + out: List[str] = [] + _check(schema, value, path, out) + return out + + +def _owning_test() -> Optional[unittest.TestCase]: + frame = inspect.currentframe() + while frame is not None: + candidate = frame.f_locals.get("self") + if isinstance(candidate, unittest.TestCase): + return candidate + frame = frame.f_back + return None + + +class RecordingTransport: + """Records every frame, after checking it against the wire schema. + + A violation is not raised from ``send_json``: the adapter's best-effort + senders catch every exception, so it would be swallowed exactly where it + matters. It is collected instead and fails the test that created the + transport at cleanup, outside any of the adapter's ``except`` blocks. + """ + + def __init__(self, *, sends: bool = True, raises_after: Any = None) -> None: + self.connected = True + self.sent: list = [] + self.violations: List[str] = [] + self._sends = sends + self._raises_after = raises_after + test = _owning_test() + if test is None: + raise RuntimeError("RecordingTransport must be created inside a TestCase") + test.addCleanup(self.assert_clean) + + def assert_clean(self) -> None: + if self.violations: + raise AssertionError( + "frame breaks the wire contract:\n " + "\n ".join(self.violations) + ) + + def send_json(self, scope: str, data: Any) -> bool: + if self._raises_after is not None and len(self.sent) >= self._raises_after: + raise OSError("socket gone") + self.violations += violations(scope, data) + self.sent.append((scope, data)) + return self._sends + + def of_scope(self, scope: str) -> list: + return [data for s, data in self.sent if s == scope] + + def close(self) -> None: + self.connected = False diff --git a/packages/shared/package.json b/packages/shared/package.json index aa8bd782..674e7ca3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,7 +23,8 @@ }, "types": "./src/index.ts", "scripts": { - "lint": "eslint ." + "lint": "eslint .", + "gen:wire-schema": "tsx scripts/wire-schema.ts" }, "license": "MIT" } diff --git a/packages/shared/scripts/wire-schema.ts b/packages/shared/scripts/wire-schema.ts new file mode 100644 index 00000000..d588adc2 --- /dev/null +++ b/packages/shared/scripts/wire-schema.ts @@ -0,0 +1,259 @@ +// Per-scope payload schemas for adapters that cannot import shared (the Python one generates its types from them). + +import { realpathSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' + +export type JsonSchema = { [key: string]: unknown } + +export interface WireSchema { + $schema: string + $comment: string + scopes: Record + $defs: Record +} + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SHARED_SRC = path.resolve(HERE, '../src') +export const WIRE_SCHEMA_PATH = path.resolve(HERE, '../wire-schema.json') + +// A virtual module, so scope names and payloads are read off shared's own maps rather than restated here. +const ENTRY_PATH = path.join(SHARED_SRC, '__wire_schema_entry__.ts') +const ENTRY_SOURCE = ` +import type { WsMessageScope, WsPayloadFor } from './ws.js' +import type { TraceExportPayloadFor } from './trace-export.js' +import type { ElementScriptsResponse } from './element-scripts.js' +export type Scopes = { [S in WsMessageScope]: WsPayloadFor } & TraceExportPayloadFor +export type Named = { ElementScriptsResponse: ElementScriptsResponse } +` + +class SchemaBuilder { + readonly defs = new Map() + + constructor( + private readonly checker: ts.TypeChecker, + private readonly shared: ts.Symbol + ) {} + + build(type: ts.Type): JsonSchema { + const { checker } = this + const f = type.flags + if (f & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return {} + } + if (f & ts.TypeFlags.Union) { + return this.#union((type as ts.UnionType).types) + } + if (f & ts.TypeFlags.StringLiteral) { + return { type: 'string', enum: [(type as ts.StringLiteralType).value] } + } + if (f & ts.TypeFlags.NumberLiteral) { + return { const: (type as ts.NumberLiteralType).value } + } + if (f & ts.TypeFlags.BooleanLiteral) { + return { const: checker.typeToString(type) === 'true' } + } + if (f & ts.TypeFlags.String) { + return { type: 'string' } + } + if (f & ts.TypeFlags.Number) { + return { type: 'number' } + } + if (f & ts.TypeFlags.Boolean) { + return { type: 'boolean' } + } + if (f & ts.TypeFlags.Null) { + return { type: 'null' } + } + if (f & ts.TypeFlags.Object) { + return this.#object(type) + } + throw new Error( + `wire-schema: unsupported type ${checker.typeToString(type)}` + ) + } + + #union(members: readonly ts.Type[]): JsonSchema { + const kept = members.filter((m) => !(m.flags & ts.TypeFlags.Undefined)) + const booleans = kept.filter((m) => m.flags & ts.TypeFlags.BooleanLiteral) + const rest = kept.filter((m) => !(m.flags & ts.TypeFlags.BooleanLiteral)) + const schemas = rest.map((m) => this.build(m)) + if (booleans.length === 2) { + schemas.push({ type: 'boolean' }) + } else { + schemas.push(...booleans.map((m) => this.build(m))) + } + const literals = schemas.filter(isStringEnum) + const merged: JsonSchema[] = literals.length + ? [ + { + type: 'string', + enum: literals.flatMap((s) => s.enum as string[]) + }, + ...schemas.filter((s) => !isStringEnum(s)) + ] + : schemas + const unique = [ + ...new Map(merged.map((s) => [JSON.stringify(s), s])).values() + ] + return unique.length === 1 ? unique[0] : { anyOf: unique } + } + + #object(type: ts.Type): JsonSchema { + const { checker } = this + const name = type.symbol?.name + // On the wire a Date is its ISO string and an Error its serialized object. + if (name === 'Date') { + return { type: 'string', format: 'date-time' } + } + if (name === 'Error') { + return this.#named( + 'SerializedError', + exportedType(this.checker, this.shared, 'SerializedError') + ) + } + if (name === 'Array' || name === 'ReadonlyArray') { + const [item] = checker.getTypeArguments(type as ts.TypeReference) + return { type: 'array', items: this.build(item) } + } + if (type.symbol && type.symbol.flags & ts.SymbolFlags.Interface) { + return this.#named(name, type) + } + return this.#shape(type) + } + + #named(name: string, type: ts.Type): JsonSchema { + if (!this.defs.has(name)) { + // Registered before it is built, so a self-referencing type terminates. + this.defs.set(name, {}) + this.defs.set(name, this.#shape(type)) + } + return { $ref: `#/$defs/${name}` } + } + + #shape(type: ts.Type): JsonSchema { + const { checker } = this + const props = checker.getPropertiesOfType(type) + const index = checker.getIndexInfosOfType(type) + if (!props.length && index.length) { + return { + type: 'object', + additionalProperties: this.build(index[0].type) + } + } + const properties: Record = {} + const required: string[] = [] + for (const prop of props) { + const decl = prop.valueDeclaration ?? prop.declarations?.[0] + if (!decl) { + throw new Error(`wire-schema: no declaration for ${prop.name}`) + } + properties[prop.name] = this.build( + checker.getTypeOfSymbolAtLocation(prop, decl) + ) + if (!(prop.flags & ts.SymbolFlags.Optional)) { + required.push(prop.name) + } + } + return { + type: 'object', + properties, + required, + additionalProperties: false + } + } +} + +function isStringEnum(s: JsonSchema): boolean { + return ( + s.type === 'string' && Array.isArray(s.enum) && Object.keys(s).length === 2 + ) +} + +function createProgram(): ts.Program { + const options: ts.CompilerOptions = { + strict: true, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ['lib.es2020.d.ts'], + skipLibCheck: true, + noEmit: true + } + const host = ts.createCompilerHost(options) + const { getSourceFile, fileExists, readFile } = host + host.fileExists = (f) => f === ENTRY_PATH || fileExists.call(host, f) + host.readFile = (f) => + f === ENTRY_PATH ? ENTRY_SOURCE : readFile.call(host, f) + host.getSourceFile = (f, lang, ...rest) => + f === ENTRY_PATH + ? ts.createSourceFile(f, ENTRY_SOURCE, lang) + : getSourceFile.call(host, f, lang, ...rest) + return ts.createProgram( + [ENTRY_PATH, path.join(SHARED_SRC, 'index.ts')], + options, + host + ) +} + +function exportedType( + checker: ts.TypeChecker, + module: ts.Symbol, + name: string +): ts.Type { + const sym = checker.getExportsOfModule(module).find((s) => s.name === name) + if (!sym) { + throw new Error(`wire-schema: ${module.name} does not export ${name}`) + } + return checker.getDeclaredTypeOfSymbol(sym) +} + +export function buildWireSchema(): WireSchema { + const program = createProgram() + const checker = program.getTypeChecker() + const entry = program.getSourceFile(ENTRY_PATH) + const index = program.getSourceFile(path.join(SHARED_SRC, 'index.ts')) + const entryModule = entry && checker.getSymbolAtLocation(entry) + const shared = index && checker.getSymbolAtLocation(index) + if (!entry || !entryModule || !shared) { + throw new Error('wire-schema: could not load shared') + } + const builder = new SchemaBuilder(checker, shared) + + const scopes: Record = {} + const scopeMap = exportedType(checker, entryModule, 'Scopes') + for (const prop of checker.getPropertiesOfType(scopeMap)) { + scopes[prop.name] = builder.build( + checker.getTypeOfSymbolAtLocation(prop, entry) + ) + } + const named = exportedType(checker, entryModule, 'Named') + for (const prop of checker.getPropertiesOfType(named)) { + builder.build(checker.getTypeOfSymbolAtLocation(prop, entry)) + } + + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $comment: + 'GENERATED by packages/shared/scripts/wire-schema.ts from shared types. Do not edit.', + scopes: sortKeys(scopes), + $defs: sortKeys(Object.fromEntries(builder.defs)) + } +} + +function sortKeys(record: Record): Record { + return Object.fromEntries( + Object.entries(record).sort(([a], [b]) => a.localeCompare(b)) + ) +} + +export function formatWireSchema(schema: WireSchema): string { + return `${JSON.stringify(schema, null, 2)}\n` +} + +const invoked = process.argv[1] && realpathSync(process.argv[1]) +if (invoked === fileURLToPath(import.meta.url)) { + writeFileSync(WIRE_SCHEMA_PATH, formatWireSchema(buildWireSchema())) + console.log(`wrote ${path.relative(process.cwd(), WIRE_SCHEMA_PATH)}`) +} diff --git a/packages/shared/src/trace-export.ts b/packages/shared/src/trace-export.ts index 5c341125..e325c692 100644 --- a/packages/shared/src/trace-export.ts +++ b/packages/shared/src/trace-export.ts @@ -70,3 +70,9 @@ export interface TraceExportResult { * the policy working rather than as a failure. */ declinedByPolicy?: boolean } + +/** Payload per trace-export scope, keyed off {@link TRACE_EXPORT_SCOPE}. */ +export type TraceExportPayloadFor = { + [TRACE_EXPORT_SCOPE.request]: TraceExportRequest + [TRACE_EXPORT_SCOPE.result]: TraceExportResult +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 936f1861..b2db8e36 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -573,7 +573,7 @@ export interface TraceLog { metadata: Metadata commands: CommandLog[] sources: Record - suites?: Record[] + suites?: Record[] screencast?: ScreencastInfo config?: { configFile?: string } /** Per-action snapshots captured in `mode: 'trace'` for the trace.zip exporter. */ diff --git a/packages/shared/tests/wire-schema.test.ts b/packages/shared/tests/wire-schema.test.ts new file mode 100644 index 00000000..fa0952c9 --- /dev/null +++ b/packages/shared/tests/wire-schema.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs' +import { beforeAll, describe, expect, it } from 'vitest' + +import { + buildWireSchema, + formatWireSchema, + WIRE_SCHEMA_PATH, + type WireSchema +} from '../scripts/wire-schema.js' + +let schema: WireSchema + +beforeAll(() => { + schema = buildWireSchema() +}, 30_000) + +describe('wire schema', () => { + it('the committed copy matches shared types', () => { + expect( + readFileSync(WIRE_SCHEMA_PATH, 'utf8'), + 'stale: run `pnpm --filter @wdio/devtools-shared gen:wire-schema`' + ).toBe(formatWireSchema(schema)) + }) + + it('keys every payload-typed scope, including the trace-export pair', () => { + expect(Object.keys(schema.scopes)).toEqual( + expect.arrayContaining([ + 'commands', + 'metadata', + 'replaceCommand', + 'suites', + 'traceExport', + 'traceExported' + ]) + ) + expect(schema.scopes.commands).toEqual({ + type: 'array', + items: { $ref: '#/$defs/CommandLog' } + }) + }) + + it('describes a Date as the ISO string it serializes to', () => { + const props = (schema.$defs.TestStats.properties ?? {}) as Record< + string, + unknown + > + expect(props.start).toEqual({ type: 'string', format: 'date-time' }) + expect(props.end).toEqual({ + anyOf: [{ type: 'null' }, { type: 'string', format: 'date-time' }] + }) + }) + + it('collapses Error | SerializedError onto SerializedError', () => { + const props = schema.$defs.CommandLog.properties as Record + expect(props.error).toEqual({ $ref: '#/$defs/SerializedError' }) + }) + + it('requires only non-optional fields, and forbids unknown ones', () => { + const log = schema.$defs.ConsoleLog + expect(log.required).toEqual(['type', 'args', 'timestamp']) + expect(log.additionalProperties).toBe(false) + }) + + it('reduces enums and literal unions to a string enum', () => { + const meta = schema.$defs.Metadata.properties as Record + expect(meta.type).toEqual({ + type: 'string', + enum: ['standalone', 'testrunner'] + }) + }) + + it('maps a Record to additionalProperties', () => { + expect(schema.scopes.sources).toEqual({ + type: 'object', + additionalProperties: { type: 'string' } + }) + }) +}) diff --git a/packages/shared/wire-schema.json b/packages/shared/wire-schema.json new file mode 100644 index 00000000..56ad1f80 --- /dev/null +++ b/packages/shared/wire-schema.json @@ -0,0 +1,1352 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "GENERATED by packages/shared/scripts/wire-schema.ts from shared types. Do not edit.", + "scopes": { + "actionSnapshots": { + "type": "array", + "items": { + "$ref": "#/$defs/ActionSnapshot" + } + }, + "baseline:cleared": { + "$ref": "#/$defs/BaselineClearedWsPayload" + }, + "baseline:saved": { + "$ref": "#/$defs/BaselineSavedWsPayload" + }, + "clearExecutionData": { + "$ref": "#/$defs/ClearExecutionDataWsPayload" + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/$defs/CommandLog" + } + }, + "config": { + "type": "object", + "properties": { + "configFile": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + }, + "consoleLogs": { + "type": "array", + "items": { + "$ref": "#/$defs/ConsoleLog" + } + }, + "logs": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "mutations": { + "type": "array", + "items": { + "$ref": "#/$defs/TraceMutation" + } + }, + "networkRequests": { + "type": "array", + "items": { + "$ref": "#/$defs/NetworkRequest" + } + }, + "replaceCommand": { + "$ref": "#/$defs/ReplaceCommandWsPayload" + }, + "screencast": { + "$ref": "#/$defs/ScreencastInfo" + }, + "screencastFrames": { + "type": "array", + "items": { + "$ref": "#/$defs/ScreencastFrame" + } + }, + "sources": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "suites": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SuiteStats" + } + } + }, + "testStopped": {}, + "traceExport": { + "$ref": "#/$defs/TraceExportRequest" + }, + "traceExported": { + "$ref": "#/$defs/TraceExportResult" + } + }, + "$defs": { + "AccessibilityNode": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "name": { + "type": "string" + }, + "selector": { + "type": "string" + }, + "depth": { + "type": "number" + }, + "level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "disabled": { + "type": "string" + }, + "checked": { + "type": "string" + }, + "expanded": { + "type": "string" + }, + "selected": { + "type": "string" + }, + "pressed": { + "type": "string" + }, + "required": { + "type": "string" + }, + "readonly": { + "type": "string" + }, + "isInViewport": { + "type": "boolean" + } + }, + "required": [ + "role", + "name", + "selector", + "depth", + "level", + "disabled", + "checked", + "expanded", + "selected", + "pressed", + "required", + "readonly" + ], + "additionalProperties": false + }, + "ActionSnapshot": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "command": { + "type": "string" + }, + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "screenshot": { + "type": "string" + }, + "elements": { + "type": "array", + "items": {} + }, + "snapshotText": { + "type": "string" + }, + "accessibilityTree": { + "type": "array", + "items": { + "$ref": "#/$defs/AccessibilityNode" + } + } + }, + "required": [ + "timestamp", + "command" + ], + "additionalProperties": false + }, + "BaselineClearedWsPayload": { + "type": "object", + "properties": { + "testUid": { + "type": "string" + } + }, + "required": [ + "testUid" + ], + "additionalProperties": false + }, + "BaselineSavedWsPayload": { + "type": "object", + "properties": { + "testUid": { + "type": "string" + }, + "attempt": { + "$ref": "#/$defs/PreservedAttempt" + } + }, + "required": [ + "testUid", + "attempt" + ], + "additionalProperties": false + }, + "ClearExecutionDataWsPayload": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "entryType": { + "type": "string", + "enum": [ + "test", + "suite" + ] + }, + "clearSuiteTree": { + "type": "boolean" + }, + "runStart": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + }, + "CommandLog": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": {} + }, + "title": { + "type": "string" + }, + "result": {}, + "error": { + "$ref": "#/$defs/SerializedError" + }, + "timestamp": { + "type": "number" + }, + "startTime": { + "type": "number" + }, + "sequence": { + "type": "number" + }, + "callSource": { + "type": "string" + }, + "screenshot": { + "type": "string" + }, + "testUid": { + "type": "string" + }, + "performance": { + "$ref": "#/$defs/PerformanceData" + }, + "cookies": { + "type": "string" + }, + "documentInfo": { + "$ref": "#/$defs/DocumentInfo" + }, + "id": { + "type": "number" + }, + "stepUid": { + "type": "string" + }, + "snapshotText": { + "type": "string" + }, + "point": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + }, + "selector": { + "type": "string" + } + }, + "required": [ + "command", + "args", + "timestamp" + ], + "additionalProperties": false + }, + "ConsoleLog": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "trace", + "debug", + "log", + "info", + "warn", + "error" + ] + }, + "args": { + "type": "array", + "items": {} + }, + "timestamp": { + "type": "number" + }, + "source": { + "type": "string", + "enum": [ + "browser", + "test", + "terminal" + ] + } + }, + "required": [ + "type", + "args", + "timestamp" + ], + "additionalProperties": false + }, + "DeviceInfo": { + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "android", + "ios" + ] + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "platform" + ], + "additionalProperties": false + }, + "DocumentInfo": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "headers": { + "type": "object", + "properties": { + "userAgent": { + "type": "string" + }, + "language": { + "type": "string" + }, + "platform": { + "type": "string" + } + }, + "required": [ + "userAgent", + "language", + "platform" + ], + "additionalProperties": false + }, + "documentInfo": { + "type": "object", + "properties": { + "readyState": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "characterSet": { + "type": "string" + } + }, + "required": [ + "readyState", + "referrer", + "characterSet" + ], + "additionalProperties": false + } + }, + "required": [ + "url", + "title", + "headers", + "documentInfo" + ], + "additionalProperties": false + }, + "ElementScriptsResponse": { + "type": "object", + "properties": { + "accessibilityTree": { + "type": "string" + }, + "elements": { + "type": "string" + } + }, + "required": [ + "accessibilityTree", + "elements" + ], + "additionalProperties": false + }, + "Metadata": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "standalone", + "testrunner" + ] + }, + "url": { + "type": "string" + }, + "options": {}, + "capabilities": {}, + "viewport": { + "$ref": "#/$defs/Viewport" + }, + "sessionId": { + "type": "string" + }, + "testEnv": { + "type": "string" + }, + "host": { + "type": "string" + }, + "modulePath": { + "type": "string" + }, + "desiredCapabilities": { + "type": "object", + "additionalProperties": {} + }, + "runner": { + "type": "string", + "enum": [ + "mocha", + "jasmine", + "cucumber", + "nightwatch", + "nightwatch-cucumber", + "selenium-webdriver" + ] + }, + "device": { + "$ref": "#/$defs/DeviceInfo" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "NetworkRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "cookies": { + "type": "array", + "items": {} + }, + "status": { + "type": "number" + }, + "statusText": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "startTime": { + "type": "number" + }, + "endTime": { + "type": "number" + }, + "time": { + "type": "number" + }, + "type": { + "type": "string", + "enum": [ + "document", + "stylesheet", + "script", + "image", + "font", + "fetch", + "xhr", + "other" + ] + }, + "initiator": { + "type": "string" + }, + "requestHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "navigation": { + "type": "string" + }, + "redirectChain": { + "type": "array", + "items": {} + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/NetworkRequest" + } + }, + "response": { + "type": "object", + "properties": { + "fromCache": { + "type": "boolean" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "mimeType": { + "type": "string" + }, + "status": { + "type": "number" + } + }, + "required": [ + "fromCache", + "headers", + "mimeType", + "status" + ], + "additionalProperties": false + }, + "error": { + "type": "string" + }, + "requestBody": { + "type": "string" + }, + "responseBody": { + "type": "string" + }, + "size": { + "type": "number" + } + }, + "required": [ + "id", + "url", + "method", + "timestamp", + "startTime", + "type" + ], + "additionalProperties": false + }, + "PerformanceData": { + "type": "object", + "properties": { + "navigation": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "timing": { + "type": "object", + "properties": { + "loadTime": { + "type": "number" + }, + "domReady": { + "type": "number" + }, + "responseTime": { + "type": "number" + }, + "dnsLookup": { + "type": "number" + }, + "tcpConnection": { + "type": "number" + }, + "serverResponse": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "url", + "timing" + ], + "additionalProperties": false + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "size": { + "type": "number" + }, + "type": { + "type": "string" + }, + "startTime": { + "type": "number" + }, + "responseEnd": { + "type": "number" + } + }, + "required": [ + "url", + "duration", + "size", + "type", + "startTime", + "responseEnd" + ], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + }, + "PreservedAttempt": { + "type": "object", + "properties": { + "testUid": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "test", + "suite" + ] + }, + "capturedAt": { + "type": "number" + }, + "window": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "test": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "fullTitle": { + "type": "string" + }, + "file": { + "type": "string" + }, + "callSource": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "duration": { + "type": "number" + }, + "state": { + "type": "string", + "enum": [ + "passed", + "failed", + "skipped", + "pending", + "running" + ] + }, + "error": { + "$ref": "#/$defs/TestError" + } + }, + "required": [], + "additionalProperties": false + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/PreservedStep" + } + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/$defs/CommandLog" + } + }, + "consoleLogs": { + "type": "array", + "items": { + "$ref": "#/$defs/ConsoleLog" + } + }, + "networkRequests": { + "type": "array", + "items": { + "$ref": "#/$defs/NetworkRequest" + } + }, + "mutations": { + "type": "array", + "items": {} + }, + "sources": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "testUid", + "scope", + "capturedAt", + "window", + "test", + "commands", + "consoleLogs", + "networkRequests", + "mutations", + "sources" + ], + "additionalProperties": false + }, + "PreservedStep": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "title": { + "type": "string" + }, + "fullTitle": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "state": { + "type": "string", + "enum": [ + "passed", + "failed", + "skipped", + "pending", + "running" + ] + }, + "error": { + "$ref": "#/$defs/TestError" + } + }, + "required": [ + "uid" + ], + "additionalProperties": false + }, + "ReplaceCommandWsPayload": { + "type": "object", + "properties": { + "oldTimestamp": { + "type": "number" + }, + "command": { + "$ref": "#/$defs/CommandLog" + } + }, + "required": [ + "oldTimestamp", + "command" + ], + "additionalProperties": false + }, + "ScreencastFrame": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "data", + "timestamp" + ], + "additionalProperties": false + }, + "ScreencastInfo": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "videoPath": { + "type": "string" + }, + "videoFile": { + "type": "string" + }, + "frameCount": { + "type": "number" + }, + "duration": { + "type": "number" + }, + "startTime": { + "type": "number" + } + }, + "required": [], + "additionalProperties": false + }, + "SerializedError": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "message": { + "type": "string" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "additionalProperties": false + }, + "SuiteStats": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "title": { + "type": "string" + }, + "fullTitle": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "suite" + ] + }, + "file": { + "type": "string" + }, + "start": { + "type": "string", + "format": "date-time" + }, + "state": { + "type": "string", + "enum": [ + "passed", + "failed", + "skipped", + "pending", + "running" + ] + }, + "end": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "tests": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/TestStats" + } + ] + } + }, + "suites": { + "type": "array", + "items": { + "$ref": "#/$defs/SuiteStats" + } + }, + "hooks": { + "type": "array", + "items": {} + }, + "_duration": { + "type": "number" + }, + "parent": { + "type": "string" + }, + "callSource": { + "type": "string" + }, + "order": { + "type": "number" + }, + "featureFile": { + "type": "string" + } + }, + "required": [ + "uid", + "cid", + "title", + "fullTitle", + "type", + "file", + "start", + "tests", + "suites", + "hooks", + "_duration" + ], + "additionalProperties": false + }, + "TestError": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "stack": { + "type": "string" + }, + "expected": {}, + "actual": {}, + "matcherResult": { + "type": "object", + "properties": { + "expected": {}, + "actual": {}, + "message": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }, + "TestStats": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "title": { + "type": "string" + }, + "fullTitle": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "passed", + "failed", + "skipped", + "pending", + "running" + ] + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "test" + ] + }, + "file": { + "type": "string" + }, + "retries": { + "type": "number" + }, + "_duration": { + "type": "number" + }, + "error": { + "$ref": "#/$defs/SerializedError" + }, + "hooks": { + "type": "array", + "items": {} + }, + "callSource": { + "type": "string" + }, + "order": { + "type": "number" + } + }, + "required": [ + "uid", + "cid", + "title", + "fullTitle", + "parent", + "state", + "start", + "end", + "type", + "file", + "retries", + "_duration" + ], + "additionalProperties": false + }, + "TraceExportRequest": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "outputDir": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "format": { + "type": "string", + "enum": [ + "zip", + "ndjson-directory" + ] + }, + "fileStem": { + "type": "string" + }, + "tracePolicy": { + "type": "string", + "enum": [ + "on", + "retain-on-failure", + "retain-on-first-failure", + "on-first-retry", + "on-all-retries", + "retain-on-failure-and-retries" + ] + }, + "traceGranularity": { + "type": "string", + "enum": [ + "test", + "session" + ] + } + }, + "required": [ + "requestId", + "outputDir", + "sessionId" + ], + "additionalProperties": false + }, + "TraceExportResult": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "path": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + }, + "declinedByPolicy": { + "type": "boolean" + } + }, + "required": [ + "requestId" + ], + "additionalProperties": false + }, + "TraceMutation": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "attributes", + "characterData", + "childList" + ] + }, + "attributeName": { + "type": "string" + }, + "attributeNamespace": { + "type": "string" + }, + "attributeValue": { + "type": "string" + }, + "newTextContent": { + "type": "string" + }, + "oldValue": { + "type": "string" + }, + "addedNodes": { + "type": "array", + "items": {} + }, + "target": { + "type": "string" + }, + "removedNodes": { + "type": "array", + "items": { + "type": "string" + } + }, + "previousSibling": { + "type": "string" + }, + "nextSibling": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "addedNodes", + "removedNodes", + "timestamp" + ], + "additionalProperties": false + }, + "Viewport": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "offsetLeft": { + "type": "number" + }, + "offsetTop": { + "type": "number" + }, + "scale": { + "type": "number" + } + }, + "required": [ + "width", + "height", + "offsetLeft", + "offsetTop", + "scale" + ], + "additionalProperties": false + } + } +}