@@ -69,6 +69,8 @@ describe("workspace gateway methods", () => {
6969 "workspaces.widget.setLayout" ,
7070 "workspaces.widget.scaffold" ,
7171 "workspaces.widget.approve" ,
72+ "workspaces.widget.state.get" ,
73+ "workspaces.widget.state.set" ,
7274 "workspaces.replace" ,
7375 "workspaces.undo" ,
7476 "workspaces.data.read" ,
@@ -81,7 +83,12 @@ describe("workspace gateway methods", () => {
8183 expect ( methods . get ( "workspaces.widget.approve" ) ?. opts ) . toEqual ( {
8284 scope : "operator.approvals" ,
8385 } ) ;
84- const readOnly = new Set ( [ "workspaces.get" , "workspaces.widget.frame" , "workspaces.data.read" ] ) ;
86+ const readOnly = new Set ( [
87+ "workspaces.get" ,
88+ "workspaces.widget.frame" ,
89+ "workspaces.widget.state.get" ,
90+ "workspaces.data.read" ,
91+ ] ) ;
8592 for ( const [ name , method ] of methods ) {
8693 if ( readOnly . has ( name ) || name === "workspaces.widget.approve" ) {
8794 continue ;
@@ -90,6 +97,66 @@ describe("workspace gateway methods", () => {
9097 }
9198 } ) ;
9299
100+ it ( "persists only the trusted-parent widget id with optimistic concurrency" , async ( ) => {
101+ await withTempStateDir ( async ( stateDir ) => {
102+ const { api, methods } = createApi ( ) ;
103+ registerWorkspaceGatewayMethods ( { api, store : new WorkspaceStore ( { stateDir } ) } ) ;
104+ const broadcast = vi . fn ( ) ;
105+
106+ const empty = await callMethod (
107+ methods . get ( "workspaces.widget.state.get" ) ! ,
108+ { widgetId : "cost-today" } ,
109+ broadcast ,
110+ ) ;
111+ expect ( empty . response ?. [ 1 ] ) . toEqual ( { state : null , version : 0 } ) ;
112+
113+ const first = await callMethod (
114+ methods . get ( "workspaces.widget.state.set" ) ! ,
115+ { widgetId : "cost-today" , state : { text : "a" } , expectedVersion : 0 } ,
116+ broadcast ,
117+ ) ;
118+ expect ( first . response ?. [ 0 ] ) . toBe ( true ) ;
119+ expect ( first . response ?. [ 1 ] ) . toEqual ( { widgetId : "cost-today" , version : 1 } ) ;
120+ expect ( broadcast ) . toHaveBeenCalledWith ( "plugin.workspaces.widget-state.changed" , {
121+ widgetId : "cost-today" ,
122+ version : 1 ,
123+ } ) ;
124+
125+ const read = await callMethod (
126+ methods . get ( "workspaces.widget.state.get" ) ! ,
127+ { widgetId : "cost-today" } ,
128+ broadcast ,
129+ ) ;
130+ expect ( read . response ?. [ 1 ] ) . toMatchObject ( { state : { text : "a" } , version : 1 } ) ;
131+
132+ broadcast . mockClear ( ) ;
133+ const stale = await callMethod (
134+ methods . get ( "workspaces.widget.state.set" ) ! ,
135+ { widgetId : "cost-today" , state : { text : "clobber" } , expectedVersion : 0 } ,
136+ broadcast ,
137+ ) ;
138+ expect ( stale . response ?. [ 0 ] ) . toBe ( false ) ;
139+ expect ( stale . response ?. [ 2 ] ?. message ) . toContain ( "version conflict" ) ;
140+ expect ( broadcast ) . not . toHaveBeenCalled ( ) ;
141+
142+ const malformed = await callMethod (
143+ methods . get ( "workspaces.widget.state.set" ) ! ,
144+ { widgetId : "cost-today" , state : null , expectedVersion : - 1 } ,
145+ broadcast ,
146+ ) ;
147+ expect ( malformed . response ?. [ 0 ] ) . toBe ( false ) ;
148+ expect ( malformed . response ?. [ 2 ] ?. message ) . toContain ( "non-negative integer" ) ;
149+
150+ const smuggled = await callMethod (
151+ methods . get ( "workspaces.widget.state.set" ) ! ,
152+ { widgetId : "cost-today" , state : { } , targetWidgetId : "other" } ,
153+ broadcast ,
154+ ) ;
155+ expect ( smuggled . response ?. [ 0 ] ) . toBe ( false ) ;
156+ expect ( smuggled . response ?. [ 2 ] ?. message ) . toContain ( "unexpected param" ) ;
157+ } ) ;
158+ } ) ;
159+
93160 it ( "returns the workspace without broadcasting and broadcasts successful writes" , async ( ) => {
94161 await withTempStateDir ( async ( stateDir ) => {
95162 const { api, methods } = createApi ( ) ;
0 commit comments