From 2a3e1dd0d160fea112497332a53f261203b0472c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 00:43:37 +0530 Subject: [PATCH 1/6] refactor(shared): type TraceLog.suites as SuiteStats records Every adapter sends Record[] under the suites scope, but the type said Record[], so nothing derived from it could describe a suite frame. The app's two fixtures build fragments, which the app reads as such, and are cast at the one place they enter a TraceLog. --- packages/app/test-ui/shell/fixtures.ts | 3 ++- packages/app/tests/data-manager.test.ts | 3 ++- packages/shared/src/types.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) 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/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. */ From 3956d8aad810aa315c5ee3630889ee6f24cf7504 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 00:43:37 +0530 Subject: [PATCH 2/6] feat(shared): generate a per-scope wire schema from shared's types An adapter that cannot import shared has had to retype its payloads by hand. scripts/wire-schema.ts reads WsPayloadFor and the new TraceExportPayloadFor with the TypeScript compiler and writes one JSON Schema per scope to wire-schema.json, mapping Date to its ISO string and Error to SerializedError as they cross the socket. A vitest test fails when the committed copy is stale, so the schema cannot drift from src. Refs #299 --- packages/shared/package.json | 3 +- packages/shared/scripts/wire-schema.ts | 259 ++++ packages/shared/src/trace-export.ts | 6 + packages/shared/tests/wire-schema.test.ts | 78 ++ packages/shared/wire-schema.json | 1352 +++++++++++++++++++++ 5 files changed, 1697 insertions(+), 1 deletion(-) create mode 100644 packages/shared/scripts/wire-schema.ts create mode 100644 packages/shared/tests/wire-schema.test.ts create mode 100644 packages/shared/wire-schema.json 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/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 + } + } +} From 775dc79277fb087bdb61be57b6583b2754264c37 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 00:43:37 +0530 Subject: [PATCH 3/6] feat(selenium-devtools-py): generate payload types and validate every frame types.py hand-mirrored shared and nothing checked the mirror; no mypy runs over this package, so a TypedDict alone enforces nothing. gen_contract.py now renders wire-schema.json into _wire_types.py, and the eight per-file fake transports become one RecordingTransport that validates each frame against the schema before recording it. That check found real drift, fixed here: null sent for callSource, metadata.url, and an in-flight request's status and endTime, and for an unfinished suite's state, where shared types each as absent; and a native viewport without offsetLeft, offsetTop and scale, which now carries 0, 0, 1 as core's fromWindow sends. Every reader uses ?., ?? or truthiness, so absent reads as null did. ElementScripts is renamed to shared's ElementScriptsResponse. Refs #299 --- .../changes/generated-wire-types.md | 11 + .../scripts/gen_contract.py | 99 ++++- .../src/selenium_devtools/_wire_types.py | 397 ++++++++++++++++++ .../src/selenium_devtools/element_scripts.py | 6 +- .../src/selenium_devtools/frames.py | 27 +- .../src/selenium_devtools/instrumentation.py | 8 +- .../src/selenium_devtools/types.py | 146 +------ .../tests/test_a11y_elements.py | 22 +- .../selenium-devtools-py/tests/test_bidi.py | 34 +- .../tests/test_capturer.py | 19 +- .../tests/test_filmstrip.py | 33 +- .../tests/test_instrumentation.py | 65 ++- .../tests/test_performance.py | 36 +- .../tests/test_screencast.py | 24 +- .../tests/test_trace_export.py | 17 +- .../tests/test_wire_contract.py | 65 +++ .../tests/wire_contract.py | 115 +++++ 17 files changed, 806 insertions(+), 318 deletions(-) create mode 100644 packages/selenium-devtools-py/changes/generated-wire-types.md create mode 100644 packages/selenium-devtools-py/src/selenium_devtools/_wire_types.py create mode 100644 packages/selenium-devtools-py/tests/test_wire_contract.py create mode 100644 packages/selenium-devtools-py/tests/wire_contract.py 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..9a7a55ca 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,7 +235,7 @@ 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) + tx = RecordingTransport(sends=False) self.assertEqual(trace_export.send_action_snapshots(tx, [{"a": 1}]), 0) self.assertEqual(trace_export.send_action_snapshots(None, [{"a": 1}]), 0) 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..3f25ea56 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"} 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..78c468a7 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_wire_contract.py @@ -0,0 +1,65 @@ +"""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_the_transport_refuses_a_bad_frame(self): + with self.assertRaises(AssertionError): + RecordingTransport().send_json("commands", [_command(callSource=None)]) + + +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..4ccdd15d --- /dev/null +++ b/packages/selenium-devtools-py/tests/wire_contract.py @@ -0,0 +1,115 @@ +"""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 json +from pathlib import Path +from typing import Any, List + + +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 + + +class RecordingTransport: + """Records every frame, after checking it against the wire schema.""" + + def __init__(self, *, sends: bool = True, raises_after: Any = None) -> None: + self.connected = True + self.sent: list = [] + self._sends = sends + self._raises_after = raises_after + + 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") + problems = violations(scope, data) + if problems: + raise AssertionError("frame breaks the wire contract:\n " + "\n ".join(problems)) + 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 From 19b530c2e57a2bcfd06aac29a5e9e5170b972e55 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 00:43:37 +0530 Subject: [PATCH 4/6] ci(python): guard the generated wire types and run on schema changes The drift step now diffs _wire_types.py beside _contract.py, and the workflow triggers on packages/shared/wire-schema.json, since the tests read it and a schema change can turn them red. --- .github/actions/python-package/action.yml | 2 +- .github/workflows/python.yml | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) 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/** From 0a6154e3380b91477c995eadd80462b090a247d9 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 00:43:37 +0530 Subject: [PATCH 5/6] docs: describe the generated wire types and how to regenerate them Closes #299 --- ARCHITECTURE.md | 2 +- packages/selenium-devtools-py/README.md | 29 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) 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/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. From fff9a4adc10215d9ff604b96fb0facdd079e575c Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 8 Oct 2026 01:39:36 +0530 Subject: [PATCH 6/6] test(selenium-devtools-py): fail a bad frame at cleanup, not inside the send RecordingTransport raised AssertionError from send_json, but the adapter's best-effort senders catch every exception, so a bad frame was swallowed wherever the test only checked the sender's return value. Violations are now collected and fail the owning test at cleanup, outside those except blocks. That surfaced two invalid fixtures, corrected here: an action snapshot of {"a": 1} and a replaced row with no command or args. --- .../tests/test_a11y_elements.py | 5 ++- .../tests/test_performance.py | 4 +- .../tests/test_wire_contract.py | 21 +++++++++-- .../tests/wire_contract.py | 37 ++++++++++++++++--- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/selenium-devtools-py/tests/test_a11y_elements.py b/packages/selenium-devtools-py/tests/test_a11y_elements.py index 9a7a55ca..ad0a9e47 100644 --- a/packages/selenium-devtools-py/tests/test_a11y_elements.py +++ b/packages/selenium-devtools-py/tests/test_a11y_elements.py @@ -235,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): + snapshot = {"timestamp": 1, "command": "click"} tx = RecordingTransport(sends=False) - self.assertEqual(trace_export.send_action_snapshots(tx, [{"a": 1}]), 0) - self.assertEqual(trace_export.send_action_snapshots(None, [{"a": 1}]), 0) + 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_performance.py b/packages/selenium-devtools-py/tests/test_performance.py index 3f25ea56..c1969614 100644 --- a/packages/selenium-devtools-py/tests/test_performance.py +++ b/packages/selenium-devtools-py/tests/test_performance.py @@ -173,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_wire_contract.py b/packages/selenium-devtools-py/tests/test_wire_contract.py index 78c468a7..f8143abe 100644 --- a/packages/selenium-devtools-py/tests/test_wire_contract.py +++ b/packages/selenium-devtools-py/tests/test_wire_contract.py @@ -41,9 +41,24 @@ def test_a_value_outside_an_enum_fails(self): def test_a_scope_shared_has_no_payload_for_fails(self): self.assertIn("no payload type", violations("madeUp", {})[0]) - def test_the_transport_refuses_a_bad_frame(self): - with self.assertRaises(AssertionError): - RecordingTransport().send_json("commands", [_command(callSource=None)]) + 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): diff --git a/packages/selenium-devtools-py/tests/wire_contract.py b/packages/selenium-devtools-py/tests/wire_contract.py index 4ccdd15d..b9c22329 100644 --- a/packages/selenium-devtools-py/tests/wire_contract.py +++ b/packages/selenium-devtools-py/tests/wire_contract.py @@ -8,9 +8,11 @@ from __future__ import annotations +import inspect import json +import unittest from pathlib import Path -from typing import Any, List +from typing import Any, List, Optional def _schema_path() -> Path: @@ -90,21 +92,46 @@ def _errors(schema: dict, value: Any, path: str) -> List[str]: 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.""" + """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") - problems = violations(scope, data) - if problems: - raise AssertionError("frame breaks the wire contract:\n " + "\n ".join(problems)) + self.violations += violations(scope, data) self.sent.append((scope, data)) return self._sends