mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(protocol): align Trace v2/v3 classification and hover steps
Require Trace v3 version const 3, reject mixed pages/states payloads, and align TypeScript selection optionality with the generated schema. Trace v2 on main already records hover steps, so the frozen v2 schema must accept them instead of dropping them during reduction. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -151,7 +151,7 @@ describe("reduceTraceSteps", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops hover steps unsupported by historical v2 clients", () => {
|
it("keeps hover steps before menu clicks", () => {
|
||||||
const { steps } = reduceTraceSteps(
|
const { steps } = reduceTraceSteps(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -167,11 +167,11 @@ describe("reduceTraceSteps", () => {
|
|||||||
],
|
],
|
||||||
"https://example.com/app",
|
"https://example.com/app",
|
||||||
);
|
);
|
||||||
expect(steps.map((s) => s.op)).toEqual(["click"]);
|
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||||
expect(steps[0]).toMatchObject({
|
expect(steps[0]).toMatchObject({
|
||||||
op: "click",
|
op: "hover",
|
||||||
page: "p1",
|
page: "p1",
|
||||||
target: { name: "Profile" },
|
target: { name: "Account" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function shouldRecordPress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
||||||
if (step.op === "hover") return false;
|
|
||||||
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -140,7 +139,12 @@ function toV2Step(
|
|||||||
effectForNavigation(step.navigated_to, urlToId),
|
effectForNavigation(step.navigated_to, urlToId),
|
||||||
);
|
);
|
||||||
case "hover":
|
case "hover":
|
||||||
return null;
|
return {
|
||||||
|
op: "hover",
|
||||||
|
id,
|
||||||
|
page,
|
||||||
|
target: step.target,
|
||||||
|
};
|
||||||
case "fill":
|
case "fill":
|
||||||
return {
|
return {
|
||||||
op: "fill",
|
op: "fill",
|
||||||
|
|||||||
@@ -793,6 +793,7 @@ export type DraftTraceStep =
|
|||||||
export type Step =
|
export type Step =
|
||||||
| ({ op: "navigate" } & StepCommon & { to: string })
|
| ({ op: "navigate" } & StepCommon & { to: string })
|
||||||
| ({ op: "click" } & StepCommon & { target: TargetDescriptor })
|
| ({ op: "click" } & StepCommon & { target: TargetDescriptor })
|
||||||
|
| ({ op: "hover" } & StepCommon & { target: TargetDescriptor })
|
||||||
| ({ op: "fill" } & StepCommon & {
|
| ({ op: "fill" } & StepCommon & {
|
||||||
target: TargetDescriptor;
|
target: TargetDescriptor;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -834,7 +835,7 @@ export type StepV3 =
|
|||||||
})
|
})
|
||||||
| ({ op: "select" } & StepCommonV3 & {
|
| ({ op: "select" } & StepCommonV3 & {
|
||||||
target: TargetDescriptorV3;
|
target: TargetDescriptorV3;
|
||||||
selection: SelectedOption[];
|
selection?: SelectedOption[];
|
||||||
})
|
})
|
||||||
| ({ op: "press" } & StepCommonV3 & {
|
| ({ op: "press" } & StepCommonV3 & {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -844,7 +845,7 @@ export type StepV3 =
|
|||||||
| ({ op: "scroll" } & StepCommonV3);
|
| ({ op: "scroll" } & StepCommonV3);
|
||||||
|
|
||||||
export interface TraceV3 {
|
export interface TraceV3 {
|
||||||
version: number;
|
version: 3;
|
||||||
recorded_at: string;
|
recorded_at: string;
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
|
|||||||
@@ -512,6 +512,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"op",
|
||||||
|
"page",
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"effect": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/StepEffectV2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0.0
|
||||||
|
},
|
||||||
|
"op": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"hover"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"description": "Reference into `pages[]`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"$ref": "#/definitions/TargetDescriptorV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -803,10 +843,10 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "uint32",
|
"const": 3
|
||||||
"minimum": 0.0
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"TraceEntry": {
|
"TraceEntry": {
|
||||||
"description": "Recording entry point — first URL the flow starts from.",
|
"description": "Recording entry point — first URL the flow starts from.",
|
||||||
@@ -900,7 +940,8 @@
|
|||||||
"$ref": "#/definitions/StepV2"
|
"$ref": "#/definitions/StepV2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,6 +512,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"op",
|
||||||
|
"page",
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"effect": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/StepEffectV2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0.0
|
||||||
|
},
|
||||||
|
"op": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"hover"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"description": "Reference into `pages[]`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"$ref": "#/definitions/TargetDescriptorV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -803,10 +843,10 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "uint32",
|
"const": 3
|
||||||
"minimum": 0.0
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"TraceEntry": {
|
"TraceEntry": {
|
||||||
"description": "Recording entry point — first URL the flow starts from.",
|
"description": "Recording entry point — first URL the flow starts from.",
|
||||||
@@ -900,7 +940,8 @@
|
|||||||
"$ref": "#/definitions/StepV2"
|
"$ref": "#/definitions/StepV2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,10 @@
|
|||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "uint32",
|
"const": 3
|
||||||
"minimum": 0.0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"FillCommit": {
|
"FillCommit": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"KeyModifier": {
|
"KeyModifier": {
|
||||||
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
|
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
|
||||||
@@ -189,6 +190,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"op",
|
||||||
|
"page",
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"effect": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/StepEffectV2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0.0
|
||||||
|
},
|
||||||
|
"op": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"hover"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"description": "Reference into `pages[]`.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"$ref": "#/definitions/TargetDescriptorV2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
"description": "Fields shared by every v2 step variant (flattened in JSON).",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! observations (VOM) captured before and after the action.
|
//! observations (VOM) captured before and after the action.
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
use super::interaction::KeyModifier;
|
use super::interaction::KeyModifier;
|
||||||
|
|
||||||
@@ -17,6 +17,28 @@ pub const TRACE_VERSION_V2: u32 = 2;
|
|||||||
pub const DEFAULT_TRACE_VERSION: u32 = 2;
|
pub const DEFAULT_TRACE_VERSION: u32 = 2;
|
||||||
pub const VOM_FORMAT_VERSION: u32 = 1;
|
pub const VOM_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
fn deserialize_trace_v3_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let version = u32::deserialize(deserializer)?;
|
||||||
|
if version != TRACE_VERSION {
|
||||||
|
return Err(serde::de::Error::custom(format!(
|
||||||
|
"unsupported trace version {version} (expected {TRACE_VERSION})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace_v3_version_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||||
|
schemars::schema::SchemaObject {
|
||||||
|
instance_type: Some(schemars::schema::InstanceType::Integer.into()),
|
||||||
|
const_value: Some(serde_json::json!(TRACE_VERSION)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Target
|
// Target
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -179,7 +201,10 @@ pub enum Step {
|
|||||||
|
|
||||||
/// Persisted user-action trace exported by `tool.record_stop` / `await`.
|
/// Persisted user-action trace exported by `tool.record_stop` / `await`.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Trace {
|
pub struct Trace {
|
||||||
|
#[serde(deserialize_with = "deserialize_trace_v3_version")]
|
||||||
|
#[schemars(schema_with = "trace_v3_version_schema")]
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub purpose: Option<String>,
|
pub purpose: Option<String>,
|
||||||
@@ -236,21 +261,22 @@ pub enum RecordedTrace {
|
|||||||
impl RecordedTrace {
|
impl RecordedTrace {
|
||||||
pub fn classify_value(v: &serde_json::Value) -> Result<Self, String> {
|
pub fn classify_value(v: &serde_json::Value) -> Result<Self, String> {
|
||||||
if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
|
if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
|
||||||
if ver == u64::from(TRACE_VERSION) {
|
if ver != u64::from(TRACE_VERSION) {
|
||||||
return serde_json::from_value(v.clone())
|
return Err(format!("unsupported trace version {ver}"));
|
||||||
.map(RecordedTrace::V3)
|
}
|
||||||
.map_err(|e| e.to_string());
|
if v.get("pages").is_some() {
|
||||||
|
return Err("trace v3 must not include legacy pages[]".into());
|
||||||
}
|
}
|
||||||
return Err(format!("unsupported trace version {ver}"));
|
|
||||||
}
|
|
||||||
if v.get("pages").is_some() && v.get("states").is_none() {
|
|
||||||
return serde_json::from_value(v.clone())
|
return serde_json::from_value(v.clone())
|
||||||
.map(RecordedTrace::V2)
|
.map(RecordedTrace::V3)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
if v.get("states").is_some() {
|
if v.get("states").is_some() {
|
||||||
|
return Err("trace v2 must not include states[]; set version: 3 for Trace v3".into());
|
||||||
|
}
|
||||||
|
if v.get("pages").is_some() {
|
||||||
return serde_json::from_value(v.clone())
|
return serde_json::from_value(v.clone())
|
||||||
.map(RecordedTrace::V3)
|
.map(RecordedTrace::V2)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
Err("ambiguous or unparseable trace".into())
|
Err("ambiguous or unparseable trace".into())
|
||||||
@@ -569,6 +595,42 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorded_trace_rejects_mixed_and_unsupported_versions() {
|
||||||
|
let v2_with_states = json!({
|
||||||
|
"recorded_at": "2026-07-21T08:00:00Z",
|
||||||
|
"entry": { "start_url": "https://example.com/" },
|
||||||
|
"pages": [{ "id": "p1", "url": "https://example.com/" }],
|
||||||
|
"states": [],
|
||||||
|
"steps": []
|
||||||
|
});
|
||||||
|
assert!(RecordedTrace::classify_value(&v2_with_states).is_err());
|
||||||
|
|
||||||
|
let v3_with_pages = json!({
|
||||||
|
"version": 3,
|
||||||
|
"recorded_at": "2026-07-21T08:00:00Z",
|
||||||
|
"stopped_by": "user_finish",
|
||||||
|
"entry": { "start_url": "https://example.com/" },
|
||||||
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
||||||
|
"pages": [{ "id": "p1", "url": "https://example.com/" }],
|
||||||
|
"states": [],
|
||||||
|
"steps": []
|
||||||
|
});
|
||||||
|
assert!(RecordedTrace::classify_value(&v3_with_pages).is_err());
|
||||||
|
|
||||||
|
let unsupported = json!({
|
||||||
|
"version": 2,
|
||||||
|
"recorded_at": "2026-07-21T08:00:00Z",
|
||||||
|
"stopped_by": "user_finish",
|
||||||
|
"entry": { "start_url": "https://example.com/" },
|
||||||
|
"recorder": { "bsk": "0.1.10", "vom": 1 },
|
||||||
|
"states": [],
|
||||||
|
"steps": []
|
||||||
|
});
|
||||||
|
assert!(RecordedTrace::classify_value(&unsupported).is_err());
|
||||||
|
assert!(serde_json::from_value::<Trace>(unsupported).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recorded_trace_schema_includes_v2_and_v3() {
|
fn recorded_trace_schema_includes_v2_and_v3() {
|
||||||
let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap();
|
let schema = serde_json::to_value(schemars::schema_for!(RecordStopResult)).unwrap();
|
||||||
@@ -577,6 +639,8 @@ mod tests {
|
|||||||
.expect("RecordedTrace schema should use oneOf");
|
.expect("RecordedTrace schema should use oneOf");
|
||||||
|
|
||||||
assert_eq!(variants.len(), 2);
|
assert_eq!(variants.len(), 2);
|
||||||
|
let trace_schema = schema["definitions"]["Trace"].clone();
|
||||||
|
assert_eq!(trace_schema["properties"]["version"]["const"], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ pub enum StepV2 {
|
|||||||
common: StepCommonV2,
|
common: StepCommonV2,
|
||||||
target: TargetDescriptorV2,
|
target: TargetDescriptorV2,
|
||||||
},
|
},
|
||||||
|
Hover {
|
||||||
|
#[serde(flatten)]
|
||||||
|
common: StepCommonV2,
|
||||||
|
target: TargetDescriptorV2,
|
||||||
|
},
|
||||||
Fill {
|
Fill {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
common: StepCommonV2,
|
common: StepCommonV2,
|
||||||
@@ -102,6 +107,7 @@ pub enum StepV2 {
|
|||||||
|
|
||||||
/// Persisted user-action trace exported by legacy `tool.record_stop` / `await`.
|
/// Persisted user-action trace exported by legacy `tool.record_stop` / `await`.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct TraceV2 {
|
pub struct TraceV2 {
|
||||||
/// RFC 3339 timestamp when recording stopped.
|
/// RFC 3339 timestamp when recording stopped.
|
||||||
pub recorded_at: String,
|
pub recorded_at: String,
|
||||||
@@ -197,7 +203,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn historical_v2_rejects_hover_steps() {
|
fn v2_hover_steps_round_trip() {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
"recorded_at": "2026-07-21T08:00:00Z",
|
"recorded_at": "2026-07-21T08:00:00Z",
|
||||||
"entry": { "start_url": "https://example.com/" },
|
"entry": { "start_url": "https://example.com/" },
|
||||||
@@ -206,10 +212,15 @@ mod tests {
|
|||||||
"op": "hover",
|
"op": "hover",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"page": "p1",
|
"page": "p1",
|
||||||
"target": { "tag": "button", "name": "结束" }
|
"target": { "tag": "span", "role": "button", "name": "Account" }
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(serde_json::from_value::<TraceV2>(value).is_err());
|
let trace: TraceV2 = serde_json::from_value(value).unwrap();
|
||||||
|
assert!(matches!(trace.steps.as_slice(), [StepV2::Hover { .. }]));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&trace).unwrap()["steps"][0]["op"],
|
||||||
|
"hover"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user