@plexspaces/sdk 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/dist/actor.js +1 -4
- package/dist/host.d.ts +103 -16
- package/dist/host.js +316 -101
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/wire/ws-frame-wire.d.ts +75 -0
- package/dist/wire/ws-frame-wire.js +412 -0
- package/dist/ws_thin_client.d.ts +79 -0
- package/dist/ws_thin_client.js +302 -0
- package/package.json +1 -1
- package/dist/actor.test.d.ts +0 -1
- package/dist/actor.test.js +0 -171
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2025 PlexSpaces Contributors
|
|
3
|
+
//
|
|
4
|
+
// WsThinClient — browser and Node.js WebSocket thin-node client.
|
|
5
|
+
//
|
|
6
|
+
// Speaks the binary protobuf WsFrame protocol defined in
|
|
7
|
+
// proto/plexspaces/v1/transport/websocket.proto.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// const client = new WsThinClient({ wsUrl: 'ws://localhost:8091/ws', jwtToken });
|
|
11
|
+
// const nodeId = await client.connect();
|
|
12
|
+
// const myId = client.localActorId('alice', 'ChatClient', 'chat');
|
|
13
|
+
// await client.tell(chatRoomId, 'send', { text: 'hello' });
|
|
14
|
+
// const reply = await client.ask(chatRoomId, 'status', {});
|
|
15
|
+
// client.onMessage((from, msgType, payload) => console.log(from, msgType, payload));
|
|
16
|
+
// await client.disconnect();
|
|
17
|
+
import { decodeWsFrame, encodeWsFrameAsk, encodeWsFrameHeartbeat, encodeWsFrameNodePing, encodeWsFrameNodeRegister, encodeWsFrameTell, } from './wire/ws-frame-wire.js';
|
|
18
|
+
// ─── Inline ULID generator (Crockford base32, ~20 lines, zero deps) ─────────
|
|
19
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
20
|
+
function newUlid() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
// 10-char timestamp component (48 bits → 10 Crockford base32 chars)
|
|
23
|
+
let t = now;
|
|
24
|
+
let ts = '';
|
|
25
|
+
for (let i = 9; i >= 0; i--) {
|
|
26
|
+
ts = CROCKFORD[t % 32] + ts;
|
|
27
|
+
t = Math.floor(t / 32);
|
|
28
|
+
}
|
|
29
|
+
// 16-char randomness component (80 bits → 16 Crockford base32 chars)
|
|
30
|
+
// 10 bytes × 8 bits = 80 bits; bit-pack into 16 × 5-bit groups.
|
|
31
|
+
const rb = new Uint8Array(10);
|
|
32
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
33
|
+
crypto.getRandomValues(rb);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
for (let i = 0; i < 10; i++)
|
|
37
|
+
rb[i] = Math.floor(Math.random() * 256);
|
|
38
|
+
}
|
|
39
|
+
let rand = '';
|
|
40
|
+
let acc = 0, bits = 0;
|
|
41
|
+
for (let i = 0; i < 10; i++) {
|
|
42
|
+
acc = (acc << 8) | rb[i];
|
|
43
|
+
bits += 8;
|
|
44
|
+
while (bits >= 5) {
|
|
45
|
+
bits -= 5;
|
|
46
|
+
rand += CROCKFORD[(acc >>> bits) & 0x1f];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// 10×8=80 bits, 80/5=16 chars exactly; bits===0 here.
|
|
50
|
+
return ts + rand; // 10 + 16 = 26 chars — valid ULID
|
|
51
|
+
}
|
|
52
|
+
// ─── WsThinClient ────────────────────────────────────────────────────────────
|
|
53
|
+
export class WsThinClient {
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.opts = opts;
|
|
56
|
+
this.ws = null;
|
|
57
|
+
this.assignedNodeId = '';
|
|
58
|
+
this.pendingAsks = new Map();
|
|
59
|
+
this.pendingPings = new Map();
|
|
60
|
+
this.pendingReg = null;
|
|
61
|
+
this.messageHandler = null;
|
|
62
|
+
this.heartbeatTimer = null;
|
|
63
|
+
this.HEARTBEAT_INTERVAL_MS = 25000;
|
|
64
|
+
this.DEFAULT_ASK_TIMEOUT_MS = 5000;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Open the WebSocket, complete the NodeRegistration handshake, and start the
|
|
68
|
+
* heartbeat loop. Returns the server-assigned node_id.
|
|
69
|
+
*/
|
|
70
|
+
async connect() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
// Build URL — append token as query param if provided
|
|
73
|
+
let url = this.opts.wsUrl;
|
|
74
|
+
if (this.opts.jwtToken) {
|
|
75
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
76
|
+
url = `${url}${sep}token=${encodeURIComponent(this.opts.jwtToken)}`;
|
|
77
|
+
}
|
|
78
|
+
this.ws = new WebSocket(url);
|
|
79
|
+
this.ws.binaryType = 'arraybuffer';
|
|
80
|
+
this.ws.onopen = () => {
|
|
81
|
+
// Send NodeRegistration as first frame
|
|
82
|
+
const nodeId = this.opts.nodeId ?? newUlid();
|
|
83
|
+
const capabilities = {};
|
|
84
|
+
if (this.opts.tenant)
|
|
85
|
+
capabilities['tenant'] = this.opts.tenant;
|
|
86
|
+
if (this.opts.namespace)
|
|
87
|
+
capabilities['namespace'] = this.opts.namespace;
|
|
88
|
+
// Include client resource hints if available (browser context)
|
|
89
|
+
if (typeof navigator !== 'undefined') {
|
|
90
|
+
capabilities['cpu_cores'] = String(navigator.hardwareConcurrency ?? 1);
|
|
91
|
+
}
|
|
92
|
+
// Collect browser resource hints for NodeResourceHints field 11
|
|
93
|
+
const resourceHints = {};
|
|
94
|
+
if (typeof navigator !== 'undefined') {
|
|
95
|
+
resourceHints.availableCores = navigator.hardwareConcurrency ?? 1;
|
|
96
|
+
}
|
|
97
|
+
const requestId = newUlid();
|
|
98
|
+
this.pendingReg = { resolve, reject };
|
|
99
|
+
const frame = encodeWsFrameNodeRegister(requestId, nodeId, '', capabilities, resourceHints);
|
|
100
|
+
this.ws.send(frame);
|
|
101
|
+
};
|
|
102
|
+
this.ws.onmessage = (ev) => {
|
|
103
|
+
const buf = ev.data instanceof ArrayBuffer
|
|
104
|
+
? new Uint8Array(ev.data)
|
|
105
|
+
: new Uint8Array(ev.data);
|
|
106
|
+
this.handleFrame(buf);
|
|
107
|
+
};
|
|
108
|
+
this.ws.onerror = () => {
|
|
109
|
+
const err = new Error('WebSocket error');
|
|
110
|
+
this.rejectAllPending(err);
|
|
111
|
+
reject(err);
|
|
112
|
+
};
|
|
113
|
+
this.ws.onclose = (ev) => {
|
|
114
|
+
const err = new Error(`WebSocket closed: ${ev.code} ${ev.reason}`);
|
|
115
|
+
this.rejectAllPending(err);
|
|
116
|
+
if (this.pendingReg) {
|
|
117
|
+
this.pendingReg.reject(err);
|
|
118
|
+
this.pendingReg = null;
|
|
119
|
+
}
|
|
120
|
+
this.stopHeartbeat();
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fire-and-forget. actorId must be the canonical form:
|
|
126
|
+
* {name}//{type}::{namespace}@{nodeId}
|
|
127
|
+
*/
|
|
128
|
+
async tell(actorId, msgType, payload) {
|
|
129
|
+
const frame = encodeWsFrameTell(newUlid(), actorId, msgType, new TextEncoder().encode(JSON.stringify(payload)));
|
|
130
|
+
this.send(frame);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Request-reply. Returns the response payload (parsed JSON).
|
|
134
|
+
* Rejects with a timeout error if no response arrives within timeoutMs.
|
|
135
|
+
*/
|
|
136
|
+
async ask(actorId, msgType, payload, timeoutMs = this.DEFAULT_ASK_TIMEOUT_MS) {
|
|
137
|
+
const requestId = newUlid();
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
this.pendingAsks.delete(requestId);
|
|
141
|
+
reject(new Error(`ask timeout after ${timeoutMs}ms for ${actorId}`));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
this.pendingAsks.set(requestId, { resolve, reject, timer });
|
|
144
|
+
const frame = encodeWsFrameAsk(requestId, actorId, msgType, new TextEncoder().encode(JSON.stringify(payload)), timeoutMs);
|
|
145
|
+
this.send(frame);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Register a handler for incoming tell frames addressed to this thin node.
|
|
150
|
+
* Called whenever the server routes a tell to one of this node's actor IDs.
|
|
151
|
+
*/
|
|
152
|
+
onMessage(handler) {
|
|
153
|
+
this.messageHandler = handler;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Send a SWIM-compatible ping to a target node and return its resource hints.
|
|
157
|
+
* The target nodeId must be known to the server (registered in SWIM membership).
|
|
158
|
+
*/
|
|
159
|
+
async pingNode(targetNodeId, timeoutMs = 5000) {
|
|
160
|
+
const requestId = newUlid();
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
this.pendingPings.delete(requestId);
|
|
164
|
+
reject(new Error(`ping timeout for ${targetNodeId}`));
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
this.pendingPings.set(requestId, { resolve, reject, timer });
|
|
167
|
+
const frame = encodeWsFrameNodePing(requestId, this.assignedNodeId, Date.now());
|
|
168
|
+
this.send(frame);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Send a heartbeat frame to keep the WS session alive. */
|
|
172
|
+
async heartbeat() {
|
|
173
|
+
const frame = encodeWsFrameHeartbeat(newUlid(), this.assignedNodeId);
|
|
174
|
+
this.send(frame);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Build a canonical actor ID on this thin node.
|
|
178
|
+
* Format: {name}//{type}::{namespace}@{assignedNodeId}
|
|
179
|
+
*/
|
|
180
|
+
localActorId(name, type, namespace) {
|
|
181
|
+
const ns = namespace ?? this.opts.namespace ?? 'default';
|
|
182
|
+
return `${name}//${type}::${ns}@${this.assignedNodeId}`;
|
|
183
|
+
}
|
|
184
|
+
/** The server-assigned node_id (available after connect() resolves). */
|
|
185
|
+
get nodeId() {
|
|
186
|
+
return this.assignedNodeId;
|
|
187
|
+
}
|
|
188
|
+
/** Generate a new ULID. Exposed so examples can use it without a dep. */
|
|
189
|
+
static newUlid() {
|
|
190
|
+
return newUlid();
|
|
191
|
+
}
|
|
192
|
+
/** Disconnect and clean up. */
|
|
193
|
+
async disconnect() {
|
|
194
|
+
this.stopHeartbeat();
|
|
195
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
196
|
+
this.ws.close(1000, 'client disconnect');
|
|
197
|
+
}
|
|
198
|
+
this.ws = null;
|
|
199
|
+
}
|
|
200
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
201
|
+
send(data) {
|
|
202
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
203
|
+
throw new Error('WsThinClient: not connected');
|
|
204
|
+
}
|
|
205
|
+
this.ws.send(data);
|
|
206
|
+
}
|
|
207
|
+
handleFrame(buf) {
|
|
208
|
+
const frame = decodeWsFrame(buf);
|
|
209
|
+
switch (frame.type) {
|
|
210
|
+
case 'node_register_ack': {
|
|
211
|
+
if (this.pendingReg) {
|
|
212
|
+
const reg = this.pendingReg;
|
|
213
|
+
this.pendingReg = null;
|
|
214
|
+
if (frame.success) {
|
|
215
|
+
this.assignedNodeId = frame.assignedNodeId;
|
|
216
|
+
this.startHeartbeat();
|
|
217
|
+
reg.resolve(frame.assignedNodeId);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
reg.reject(new Error(`registration failed: ${frame.errorMessage}`));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'ask_response': {
|
|
226
|
+
const pending = this.pendingAsks.get(frame.requestId);
|
|
227
|
+
if (pending) {
|
|
228
|
+
clearTimeout(pending.timer);
|
|
229
|
+
this.pendingAsks.delete(frame.requestId);
|
|
230
|
+
if (frame.success) {
|
|
231
|
+
pending.resolve(frame.payloadJson);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
pending.reject(new Error(frame.errorMessage || 'ask failed'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case 'tell_response':
|
|
240
|
+
// Fire-and-forget — nothing to resolve
|
|
241
|
+
break;
|
|
242
|
+
case 'heartbeat_ack':
|
|
243
|
+
// Heartbeat acknowledged — nothing to do
|
|
244
|
+
break;
|
|
245
|
+
case 'node_ping_response': {
|
|
246
|
+
const pending = this.pendingPings.get(frame.requestId);
|
|
247
|
+
if (pending) {
|
|
248
|
+
clearTimeout(pending.timer);
|
|
249
|
+
this.pendingPings.delete(frame.requestId);
|
|
250
|
+
pending.resolve({
|
|
251
|
+
success: true,
|
|
252
|
+
nodeId: frame.nodeId,
|
|
253
|
+
cpuPercent: frame.cpuPercent,
|
|
254
|
+
memoryAvailableMb: frame.memoryAvailableMb,
|
|
255
|
+
availableCores: frame.availableCores,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'incoming_tell': {
|
|
261
|
+
if (this.messageHandler) {
|
|
262
|
+
this.messageHandler(frame.actorId, frame.msgType, frame.payloadJson);
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case 'error': {
|
|
267
|
+
// Reject the matching pending ask if one exists
|
|
268
|
+
const pending = this.pendingAsks.get(frame.requestId);
|
|
269
|
+
if (pending) {
|
|
270
|
+
clearTimeout(pending.timer);
|
|
271
|
+
this.pendingAsks.delete(frame.requestId);
|
|
272
|
+
pending.reject(new Error(`server error ${frame.code}: ${frame.message}`));
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
// 'unknown' — silently ignored (forward-compatible)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
startHeartbeat() {
|
|
280
|
+
this.heartbeatTimer = setInterval(() => {
|
|
281
|
+
this.heartbeat().catch(() => { });
|
|
282
|
+
}, this.HEARTBEAT_INTERVAL_MS);
|
|
283
|
+
}
|
|
284
|
+
stopHeartbeat() {
|
|
285
|
+
if (this.heartbeatTimer !== null) {
|
|
286
|
+
clearInterval(this.heartbeatTimer);
|
|
287
|
+
this.heartbeatTimer = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
rejectAllPending(err) {
|
|
291
|
+
for (const [, p] of this.pendingAsks) {
|
|
292
|
+
clearTimeout(p.timer);
|
|
293
|
+
p.reject(err);
|
|
294
|
+
}
|
|
295
|
+
this.pendingAsks.clear();
|
|
296
|
+
for (const [, p] of this.pendingPings) {
|
|
297
|
+
clearTimeout(p.timer);
|
|
298
|
+
p.reject(err);
|
|
299
|
+
}
|
|
300
|
+
this.pendingPings.clear();
|
|
301
|
+
}
|
|
302
|
+
}
|
package/package.json
CHANGED
package/dist/actor.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/actor.test.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
// Copyright (C) 2025 PlexSpaces Contributors
|
|
3
|
-
//
|
|
4
|
-
// Unit tests for PlexSpacesActor base class
|
|
5
|
-
import { PlexSpacesActor } from './actor';
|
|
6
|
-
// Mock host.log for testing
|
|
7
|
-
let mockHostLog;
|
|
8
|
-
// Override the import for testing (in real WASM, jco provides this)
|
|
9
|
-
// @ts-ignore - Test override
|
|
10
|
-
global.hostLog = undefined;
|
|
11
|
-
class TestActor extends PlexSpacesActor {
|
|
12
|
-
getDefaultState() {
|
|
13
|
-
return { count: 0, name: '' };
|
|
14
|
-
}
|
|
15
|
-
onIncrement(payload) {
|
|
16
|
-
const amount = Number(payload.amount ?? 1);
|
|
17
|
-
this.state.count += amount;
|
|
18
|
-
return { count: this.state.count };
|
|
19
|
-
}
|
|
20
|
-
onSetName(payload) {
|
|
21
|
-
this.state.name = String(payload.name ?? '');
|
|
22
|
-
return { name: this.state.name };
|
|
23
|
-
}
|
|
24
|
-
onGet() {
|
|
25
|
-
return { count: this.state.count, name: this.state.name };
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
describe('PlexSpacesActor', () => {
|
|
29
|
-
let actor;
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
actor = new TestActor();
|
|
32
|
-
mockHostLog = undefined;
|
|
33
|
-
});
|
|
34
|
-
describe('init', () => {
|
|
35
|
-
it('should initialize with default state', () => {
|
|
36
|
-
const result = actor.init('{}');
|
|
37
|
-
expect(result).toBe('');
|
|
38
|
-
expect(actor.state.count).toBe(0);
|
|
39
|
-
expect(actor.state.name).toBe('');
|
|
40
|
-
});
|
|
41
|
-
it('should call onInit with parsed config', () => {
|
|
42
|
-
const config = JSON.stringify({ name: 'test-actor' });
|
|
43
|
-
const result = actor.init(config);
|
|
44
|
-
expect(result).toBe('');
|
|
45
|
-
});
|
|
46
|
-
it('should return error on invalid JSON', () => {
|
|
47
|
-
const result = actor.init('invalid json');
|
|
48
|
-
expect(result).toContain('ERROR');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
describe('handle', () => {
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
actor.init('{}');
|
|
54
|
-
});
|
|
55
|
-
it('should dispatch to onIncrement handler', () => {
|
|
56
|
-
const payload = JSON.stringify({ op: 'increment', amount: 5 });
|
|
57
|
-
const result = actor.handle('sender', 'call', payload);
|
|
58
|
-
const parsed = JSON.parse(result);
|
|
59
|
-
expect(parsed.count).toBe(5);
|
|
60
|
-
expect(actor.state.count).toBe(5);
|
|
61
|
-
});
|
|
62
|
-
it('should dispatch to onSetName handler', () => {
|
|
63
|
-
const payload = JSON.stringify({ op: 'set_name', name: 'test' });
|
|
64
|
-
const result = actor.handle('sender', 'call', payload);
|
|
65
|
-
const parsed = JSON.parse(result);
|
|
66
|
-
expect(parsed.name).toBe('test');
|
|
67
|
-
expect(actor.state.name).toBe('test');
|
|
68
|
-
});
|
|
69
|
-
it('should return error for unknown operation', () => {
|
|
70
|
-
const payload = JSON.stringify({ op: 'unknown' });
|
|
71
|
-
const result = actor.handle('sender', 'call', payload);
|
|
72
|
-
const parsed = JSON.parse(result);
|
|
73
|
-
expect(parsed.error).toBe('unknown_op');
|
|
74
|
-
});
|
|
75
|
-
it('should handle handler errors gracefully', () => {
|
|
76
|
-
class ErrorActor extends PlexSpacesActor {
|
|
77
|
-
getDefaultState() {
|
|
78
|
-
return { count: 0, name: '' };
|
|
79
|
-
}
|
|
80
|
-
onError() {
|
|
81
|
-
throw new Error('Handler error');
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
const errorActor = new ErrorActor();
|
|
85
|
-
errorActor.init('{}');
|
|
86
|
-
const payload = JSON.stringify({ op: 'error' });
|
|
87
|
-
const result = errorActor.handle('sender', 'call', payload);
|
|
88
|
-
expect(result).toContain('ERROR');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
describe('getState', () => {
|
|
92
|
-
it('should return serialized state', () => {
|
|
93
|
-
actor.init('{}');
|
|
94
|
-
actor.state.count = 42;
|
|
95
|
-
actor.state.name = 'test';
|
|
96
|
-
const stateJson = actor.getState();
|
|
97
|
-
const parsed = JSON.parse(stateJson);
|
|
98
|
-
expect(parsed.count).toBe(42);
|
|
99
|
-
expect(parsed.name).toBe('test');
|
|
100
|
-
});
|
|
101
|
-
it('should cache serialized state', () => {
|
|
102
|
-
actor.init('{}');
|
|
103
|
-
const state1 = actor.getState();
|
|
104
|
-
const state2 = actor.getState();
|
|
105
|
-
expect(state1).toBe(state2);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
describe('setState', () => {
|
|
109
|
-
it('should restore state from JSON', () => {
|
|
110
|
-
const stateJson = JSON.stringify({ count: 100, name: 'restored' });
|
|
111
|
-
const result = actor.setState(stateJson);
|
|
112
|
-
expect(result).toBe('');
|
|
113
|
-
expect(actor.state.count).toBe(100);
|
|
114
|
-
expect(actor.state.name).toBe('restored');
|
|
115
|
-
});
|
|
116
|
-
it('should return error on invalid JSON', () => {
|
|
117
|
-
const result = actor.setState('invalid json');
|
|
118
|
-
expect(result).toContain('ERROR');
|
|
119
|
-
});
|
|
120
|
-
it('should handle empty state', () => {
|
|
121
|
-
const result = actor.setState('');
|
|
122
|
-
expect(result).toBe('');
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
describe('logging (actorLog)', () => {
|
|
126
|
-
// Note: actorLog is not exported, so we test it indirectly through error handling
|
|
127
|
-
// In a real WASM environment, hostLog would be provided by jco componentize
|
|
128
|
-
it('should handle errors without throwing', () => {
|
|
129
|
-
// This test verifies that logging failures don't crash the actor
|
|
130
|
-
class LoggingActor extends PlexSpacesActor {
|
|
131
|
-
getDefaultState() {
|
|
132
|
-
return { count: 0, name: '' };
|
|
133
|
-
}
|
|
134
|
-
onTest() {
|
|
135
|
-
// This will trigger actorLog internally on error
|
|
136
|
-
return { status: 'ok' };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
const loggingActor = new LoggingActor();
|
|
140
|
-
loggingActor.init('{}');
|
|
141
|
-
const payload = JSON.stringify({ op: 'test' });
|
|
142
|
-
// Should not throw even if hostLog is unavailable
|
|
143
|
-
expect(() => {
|
|
144
|
-
loggingActor.handle('sender', 'call', payload);
|
|
145
|
-
}).not.toThrow();
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
describe('safeStringify', () => {
|
|
149
|
-
it('should serialize simple objects', () => {
|
|
150
|
-
actor.init('{}');
|
|
151
|
-
const obj = { a: 1, b: 'test' };
|
|
152
|
-
const result = actor.safeStringify(obj);
|
|
153
|
-
expect(result).toBe(JSON.stringify(obj));
|
|
154
|
-
});
|
|
155
|
-
it('should handle circular references', () => {
|
|
156
|
-
actor.init('{}');
|
|
157
|
-
const obj = { a: 1 };
|
|
158
|
-
obj.self = obj;
|
|
159
|
-
// Should not throw
|
|
160
|
-
expect(() => {
|
|
161
|
-
actor.safeStringify(obj);
|
|
162
|
-
}).not.toThrow();
|
|
163
|
-
});
|
|
164
|
-
it('should handle large arrays', () => {
|
|
165
|
-
actor.init('{}');
|
|
166
|
-
const largeArray = Array.from({ length: 1000 }, (_, i) => i);
|
|
167
|
-
const result = actor.safeStringify(largeArray);
|
|
168
|
-
expect(result).toBe(JSON.stringify(largeArray));
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
});
|