Просмотр исходного кода

fix(simulation): validate semantic click identity (#37808)

Kit Langton 4 недель назад
Родитель
Сommit
71cb419570

+ 9 - 0
packages/simulation/src/frontend/actions.ts

@@ -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)
       if (!target || !target.visible || target.isDestroyed)
         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 (
         !Number.isFinite(action.x) ||
         action.x < 0 ||

+ 1 - 0
packages/simulation/src/frontend/server.ts

@@ -48,6 +48,7 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request)
         target: request.params.target,
         x: request.params.x,
         y: request.params.y,
+        semantic: request.params.semantic,
       })
     case "ui.resize":
       return SimulationActions.execute(harness, {

+ 21 - 2
packages/simulation/src/protocol/index.ts

@@ -171,6 +171,7 @@ export namespace Frontend {
     "ui.arrow",
     "ui.focus",
     "ui.click",
+    "ui.click.semantic",
     "ui.resize",
     "ui.matches",
     "ui.screenshot",
@@ -189,13 +190,26 @@ export namespace Frontend {
   })
   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([
     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.enter") }),
     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.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 }),
   ])
   export type Action = Schema.Schema.Type<typeof Action>
@@ -313,7 +327,12 @@ export namespace Frontend {
   export const FocusParams = Schema.Struct({ target: Schema.Number })
   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 const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })

+ 52 - 0
packages/simulation/test/actions.test.ts

@@ -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 () => {
   await Effect.runPromise(
     Effect.scoped(

+ 6 - 1
packages/simulation/test/frontend-server.test.ts

@@ -39,7 +39,12 @@ test("scopes the frontend control server and reports malformed JSON", async () =
             protocolVersion: 1,
             role: "ui",
             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",
+            ]),
           },
         })
 

+ 29 - 0
packages/simulation/test/protocol.test.ts

@@ -21,6 +21,35 @@ test("decodes ui.matches text params", () => {
   ).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", () => {
   expect(
     Frontend.decodeRequest({