fix(simulation): validate semantic click identity (#37808)
This commit is contained in:
parent
edc93ceff1
commit
71cb419570
6 changed files with 118 additions and 3 deletions
|
|
@ -208,6 +208,15 @@ export const execute = Effect.fn("SimulationActions.execute")(function* (harness
|
||||||
const target = all(harness.renderer.root).find((item) => item.num === action.target)
|
const target = all(harness.renderer.root).find((item) => item.num === action.target)
|
||||||
if (!target || !target.visible || target.isDestroyed)
|
if (!target || !target.visible || target.isDestroyed)
|
||||||
return yield* Effect.fail(new Error(`click target is stale or unavailable: ${action.target}`))
|
return yield* Effect.fail(new Error(`click target is stale or unavailable: ${action.target}`))
|
||||||
|
if (action.semantic) {
|
||||||
|
const current = snapshot(harness).nodes.find((node) => node.element === action.target)
|
||||||
|
if (
|
||||||
|
current?.id !== action.semantic.id ||
|
||||||
|
current.instance !== action.semantic.instance ||
|
||||||
|
current.element !== action.semantic.element
|
||||||
|
)
|
||||||
|
return yield* Effect.fail(new Error(`semantic click target is stale or unavailable: ${action.semantic.id}`))
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(action.x) ||
|
!Number.isFinite(action.x) ||
|
||||||
action.x < 0 ||
|
action.x < 0 ||
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request)
|
||||||
target: request.params.target,
|
target: request.params.target,
|
||||||
x: request.params.x,
|
x: request.params.x,
|
||||||
y: request.params.y,
|
y: request.params.y,
|
||||||
|
semantic: request.params.semantic,
|
||||||
})
|
})
|
||||||
case "ui.resize":
|
case "ui.resize":
|
||||||
return SimulationActions.execute(harness, {
|
return SimulationActions.execute(harness, {
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ export namespace Frontend {
|
||||||
"ui.arrow",
|
"ui.arrow",
|
||||||
"ui.focus",
|
"ui.focus",
|
||||||
"ui.click",
|
"ui.click",
|
||||||
|
"ui.click.semantic",
|
||||||
"ui.resize",
|
"ui.resize",
|
||||||
"ui.matches",
|
"ui.matches",
|
||||||
"ui.screenshot",
|
"ui.screenshot",
|
||||||
|
|
@ -189,13 +190,26 @@ export namespace Frontend {
|
||||||
})
|
})
|
||||||
export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {}
|
export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {}
|
||||||
|
|
||||||
|
export const SemanticClickTarget = Schema.Struct({
|
||||||
|
id: Schema.NonEmptyString,
|
||||||
|
instance: Schema.optionalKey(Schema.NonEmptyString),
|
||||||
|
element: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)),
|
||||||
|
})
|
||||||
|
export interface SemanticClickTarget extends Schema.Schema.Type<typeof SemanticClickTarget> {}
|
||||||
|
|
||||||
export const Action = Schema.Union([
|
export const Action = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }),
|
Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
|
Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.enter") }),
|
Schema.Struct({ type: Schema.Literal("ui.enter") }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
|
Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
|
Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
|
Schema.Struct({
|
||||||
|
type: Schema.Literal("ui.click"),
|
||||||
|
target: Schema.Number,
|
||||||
|
x: Schema.Number,
|
||||||
|
y: Schema.Number,
|
||||||
|
semantic: Schema.optionalKey(SemanticClickTarget),
|
||||||
|
}),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.resize"), cols: Schema.Number, rows: Schema.Number }),
|
Schema.Struct({ type: Schema.Literal("ui.resize"), cols: Schema.Number, rows: Schema.Number }),
|
||||||
])
|
])
|
||||||
export type Action = Schema.Schema.Type<typeof Action>
|
export type Action = Schema.Schema.Type<typeof Action>
|
||||||
|
|
@ -313,7 +327,12 @@ export namespace Frontend {
|
||||||
export const FocusParams = Schema.Struct({ target: Schema.Number })
|
export const FocusParams = Schema.Struct({ target: Schema.Number })
|
||||||
export interface FocusParams extends Schema.Schema.Type<typeof FocusParams> {}
|
export interface FocusParams extends Schema.Schema.Type<typeof FocusParams> {}
|
||||||
|
|
||||||
export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
|
export const ClickParams = Schema.Struct({
|
||||||
|
target: Schema.Number,
|
||||||
|
x: Schema.Number,
|
||||||
|
y: Schema.Number,
|
||||||
|
semantic: Schema.optionalKey(SemanticClickTarget),
|
||||||
|
})
|
||||||
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
|
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
|
||||||
|
|
||||||
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
|
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,58 @@ test("clicks a target at relative coordinates through descendant text", async ()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rejects a semantic click when the live identity does not match", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const renderer = yield* SimulationRenderer.create({})
|
||||||
|
let clicks = 0
|
||||||
|
const button = new BoxRenderable(renderer, {
|
||||||
|
id: "session.permission.action.once",
|
||||||
|
width: 12,
|
||||||
|
height: 1,
|
||||||
|
onMouseUp: () => clicks++,
|
||||||
|
})
|
||||||
|
SimulationSemantics.bind(() => ({
|
||||||
|
instance: "permission-2",
|
||||||
|
role: "option",
|
||||||
|
label: "Allow once",
|
||||||
|
}))(button)
|
||||||
|
renderer.root.add(button)
|
||||||
|
const harness = createHarness(renderer)
|
||||||
|
yield* Effect.promise(() => harness.renderOnce())
|
||||||
|
|
||||||
|
const error = yield* execute(harness, {
|
||||||
|
type: "ui.click",
|
||||||
|
target: button.num,
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
semantic: {
|
||||||
|
id: "session.permission.action.once",
|
||||||
|
instance: "permission-1",
|
||||||
|
element: button.num,
|
||||||
|
},
|
||||||
|
}).pipe(Effect.flip)
|
||||||
|
expect(error.message).toContain("semantic click target is stale or unavailable")
|
||||||
|
expect(clicks).toBe(0)
|
||||||
|
|
||||||
|
yield* execute(harness, {
|
||||||
|
type: "ui.click",
|
||||||
|
target: button.num,
|
||||||
|
x: 1,
|
||||||
|
y: 0,
|
||||||
|
semantic: {
|
||||||
|
id: "session.permission.action.once",
|
||||||
|
instance: "permission-2",
|
||||||
|
element: button.num,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(clicks).toBe(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("snapshots lazy semantic hierarchy and interaction state", async () => {
|
test("snapshots lazy semantic hierarchy and interaction state", async () => {
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,12 @@ test("scopes the frontend control server and reports malformed JSON", async () =
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
role: "ui",
|
role: "ui",
|
||||||
server: { name: "opencode", version: expect.any(String) },
|
server: { name: "opencode", version: expect.any(String) },
|
||||||
capabilities: expect.arrayContaining(["ui.state", "ui.snapshot", "ui.capture"]),
|
capabilities: expect.arrayContaining([
|
||||||
|
"ui.state",
|
||||||
|
"ui.snapshot",
|
||||||
|
"ui.click.semantic",
|
||||||
|
"ui.capture",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,35 @@ test("decodes ui.matches text params", () => {
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("decodes semantic click identity", () => {
|
||||||
|
expect(
|
||||||
|
Frontend.decodeRequest({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "ui.click",
|
||||||
|
params: {
|
||||||
|
target: 12,
|
||||||
|
x: 4,
|
||||||
|
y: 0,
|
||||||
|
semantic: {
|
||||||
|
id: "session.permission.action.once",
|
||||||
|
instance: "permission-1",
|
||||||
|
element: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
method: "ui.click",
|
||||||
|
params: {
|
||||||
|
semantic: {
|
||||||
|
id: "session.permission.action.once",
|
||||||
|
instance: "permission-1",
|
||||||
|
element: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("decodes semantic UI snapshots", () => {
|
test("decodes semantic UI snapshots", () => {
|
||||||
expect(
|
expect(
|
||||||
Frontend.decodeRequest({
|
Frontend.decodeRequest({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue