@plexspaces/sdk 0.1.1

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/dist/actor.js ADDED
@@ -0,0 +1,484 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2025 PlexSpaces Contributors
3
+ //
4
+ // PlexSpaces Actor base class (TypeScript SDK)
5
+ //
6
+ // Use inheritance to define actors with minimal boilerplate. Override
7
+ // getDefaultState() and onOpName(payload) methods; the base class handles
8
+ // init/handle/getState/setState and dispatch by payload.op.
9
+ /**
10
+ * WIT host log function (imported from plexspaces:actor/host).
11
+ *
12
+ * jco componentize uses virtual imports for WIT host interfaces.
13
+ * Pattern: import { functionName } from 'namespace:package/interface@version'
14
+ *
15
+ * The import is provided by the runtime when the component is instantiated.
16
+ * In non-WASM environments (Node.js verification), this import will be undefined.
17
+ *
18
+ * Type definitions are generated in src/generated/ but are not imported here
19
+ * to keep the SDK simple. The virtual import works at runtime without types.
20
+ */
21
+ // @ts-ignore - Virtual import provided by jco componentize at runtime
22
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
23
+ // @ts-expect-error - Virtual import - types are optional (generated in src/generated/)
24
+ import { log as hostLog } from 'plexspaces:actor/host@0.1.0';
25
+ import { getActorDefinition } from './decorators.js';
26
+ import { decodeWitPayloadUtf8, encodeWitPayloadUtf8 } from './wit-payload.js';
27
+ /**
28
+ * Safe logging helper that uses WIT host.log function.
29
+ *
30
+ * Logging must never throw - if host.log is unavailable or fails,
31
+ * we silently ignore (critical for WASM safety).
32
+ *
33
+ * @param level Log level (trace, debug, info, warn, error)
34
+ * @param location Code location (file:line)
35
+ * @param message Log message
36
+ * @param extra Optional extra string (already serialized, no recursion)
37
+ */
38
+ function actorLog(level, location, message, extra) {
39
+ try {
40
+ // Call host log function if available (provided by jco componentize/runtime)
41
+ if (typeof hostLog === 'function') {
42
+ const entry = extra ? `[${location}] ${message} ${extra}` : `[${location}] ${message}`;
43
+ // Call WIT host log function (virtual import provided by jco componentize)
44
+ hostLog(level, entry);
45
+ }
46
+ // If host.log is not available, silently ignore (no-op)
47
+ // This allows the code to work in non-WASM environments (e.g., Node.js verification)
48
+ }
49
+ catch {
50
+ // Logging must never throw - swallow all errors silently
51
+ }
52
+ }
53
+ /**
54
+ * Base class for PlexSpaces actors (actor-world WIT: init, handle, get-state, set-state).
55
+ *
56
+ * Subclass and override:
57
+ * - getDefaultState(): initial state
58
+ * - onInit(config): optional, called from init() with parsed config
59
+ * - on<Op>(payload): handler for message op (e.g. onDeposit, onBalance).
60
+ * Op is derived from payload.op or payload; method name is "on" + capitalized op.
61
+ *
62
+ * Example:
63
+ * class BankAccountActor extends PlexSpacesActor<BankAccountState> {
64
+ * getDefaultState() { return { account_id: '', balance: 0, transactions: [] }; }
65
+ * onDeposit(p: { amount: number }) { ...; return { status: 'ok', balance: this.state.balance }; }
66
+ * onBalance() { return { account: this.state.account_id, balance: this.state.balance }; }
67
+ * }
68
+ */
69
+ export class PlexSpacesActor {
70
+ constructor() {
71
+ // Cache serialized state to avoid WASM traps when getState() is called after handle()
72
+ this.cachedStateJson = null;
73
+ this.state = this.getDefaultState();
74
+ // Don't serialize state in constructor — defer to getState()
75
+ // The framework re-instantiates after every handle(), so caching here is wasted
76
+ this.cachedStateJson = null;
77
+ }
78
+ /** Optional: called from init() with parsed config. Override to apply config to state. */
79
+ onInit(_config) {
80
+ // default: no-op; subclass can set state from config
81
+ }
82
+ /**
83
+ * WIT `init(config: payload) -> result<_, actor-error>`.
84
+ * Success: return (unit). Failure: throw (jco maps throws to `err` for function-return `result`).
85
+ */
86
+ init(configJson) {
87
+ try {
88
+ const text = decodeWitPayloadUtf8(configJson);
89
+ const config = text.trim() ? JSON.parse(text) : {};
90
+ this.onInit(config);
91
+ // Cache state after init (state is typically small/flat here)
92
+ this.cachedStateJson = null; // Invalidate; lazy-serialize in getState()
93
+ }
94
+ catch {
95
+ throw new Error('ERROR:init failed');
96
+ }
97
+ }
98
+ /**
99
+ * WIT `handle(...) -> result<payload, actor-error>` (`payload` is `list<u8>` → `Uint8Array` in jco).
100
+ * Dispatches by msgType for Workflow behavior (workflow_run, workflow_signal:name, workflow_query:name),
101
+ * then by payload.op (or payload) to on<Op>(payload). Returns UTF-8 JSON bytes.
102
+ * Uses iterative serializer to avoid WASM recursion.
103
+ *
104
+ * Workflow behavior (aligned with Rust Workflow trait and Python @workflow_actor):
105
+ * - msgType "workflow_run" -> run(payload)
106
+ * - msgType "workflow_signal:name" -> signal(name, payload)
107
+ * - msgType "workflow_query:name" -> query(name, payload)
108
+ */
109
+ handle(_fromActor, msgType, payloadJson) {
110
+ try {
111
+ const text = decodeWitPayloadUtf8(payloadJson);
112
+ const payload = text.trim() ? JSON.parse(text) : {};
113
+ const definition = getActorDefinition(this);
114
+ // Workflow behavior: route by msgType when actor implements run/signal/query (aligned with crates/behavior Workflow trait)
115
+ if (msgType === "workflow_run") {
116
+ const runMethod = definition?.runHandler;
117
+ const runFn = runMethod
118
+ ? this[runMethod]
119
+ : this.run;
120
+ if (typeof runFn === "function") {
121
+ const result = runFn.call(this, payload);
122
+ this.cachedStateJson = null;
123
+ return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
124
+ }
125
+ }
126
+ if (msgType.startsWith("workflow_signal:")) {
127
+ const name = msgType.slice("workflow_signal:".length).trim();
128
+ const signalMethod = definition?.signalHandlers?.[name];
129
+ const signalFn = signalMethod
130
+ ? this[signalMethod]
131
+ : this.signal;
132
+ if (typeof signalFn === "function") {
133
+ if (signalMethod) {
134
+ signalFn.call(this, payload);
135
+ }
136
+ else {
137
+ signalFn.call(this, name, payload);
138
+ }
139
+ this.cachedStateJson = null;
140
+ return encodeWitPayloadUtf8('{}');
141
+ }
142
+ }
143
+ if (msgType.startsWith("workflow_query:")) {
144
+ const name = msgType.slice("workflow_query:".length).trim();
145
+ const queryMethod = definition?.queryHandlers?.[name];
146
+ const queryFn = queryMethod
147
+ ? this[queryMethod]
148
+ : this.query;
149
+ if (typeof queryFn === "function") {
150
+ const result = queryMethod
151
+ ? queryFn.call(this, payload)
152
+ : queryFn.call(this, name, payload);
153
+ return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
154
+ }
155
+ }
156
+ // Payload key order: message_type -> op -> msg_type; fallback to msgType so data-only payloads route by message type (e.g. tasks_ready)
157
+ const opRaw = payload.message_type ?? payload.op ?? payload.msg_type;
158
+ const op = (typeof opRaw === "string" && opRaw ? opRaw : msgType);
159
+ const decoratedMethod = this.resolveDecoratedHandler(op, definition);
160
+ const opKey = typeof op === "string" ? this.capitalize(op) : "";
161
+ const methodName = decoratedMethod ?? (opKey ? `on${opKey}` : "");
162
+ const method = methodName && typeof this[methodName] === "function"
163
+ ? this[methodName]
164
+ : null;
165
+ if (method) {
166
+ let result;
167
+ try {
168
+ result = method.call(this, payload);
169
+ }
170
+ catch (handlerError) {
171
+ const errorMsg = handlerError instanceof Error ? handlerError.message : String(handlerError);
172
+ actorLog('error', 'actor.ts:handle', `Handler ${methodName} failed`, errorMsg);
173
+ throw new Error('ERROR:' + errorMsg);
174
+ }
175
+ // Do not cache state here. The framework re-instantiates after every handle()
176
+ // (wasmtime#8943 workaround), so caching is wasted work that risks stack overflow.
177
+ // State will be serialized lazily in getState() if needed.
178
+ this.cachedStateJson = null;
179
+ // Use iterative serializer to avoid WASM recursion.
180
+ // The iterative serializer uses an explicit work stack instead of recursive function calls.
181
+ try {
182
+ return encodeWitPayloadUtf8(iterativeStringify(result ?? {}));
183
+ }
184
+ catch (jsonError) {
185
+ const errorMsg = jsonError instanceof Error ? jsonError.message : String(jsonError);
186
+ actorLog('error', 'actor.ts:handle', 'JSON serialization failed', errorMsg);
187
+ throw new Error('ERROR:JSON serialization failed: ' + errorMsg);
188
+ }
189
+ }
190
+ actorLog('warn', 'actor.ts:handle', 'Unknown operation', String(op));
191
+ return encodeWitPayloadUtf8(iterativeStringify({ error: 'unknown_op', op: String(op) }));
192
+ }
193
+ catch (e) {
194
+ const errorMsg = e instanceof Error ? e.message : String(e);
195
+ actorLog('error', 'actor.ts:handle', 'Handle failed', errorMsg);
196
+ if (e instanceof Error && errorMsg.startsWith('ERROR:')) {
197
+ throw e;
198
+ }
199
+ throw new Error('ERROR:' + errorMsg);
200
+ }
201
+ }
202
+ /** WIT `get-state() -> result<payload, actor-error>`. Returns JSON state as UTF-8 bytes. */
203
+ getState() {
204
+ if (this.cachedStateJson !== null) {
205
+ return encodeWitPayloadUtf8(this.cachedStateJson);
206
+ }
207
+ try {
208
+ const serialized = iterativeStringify(this.state);
209
+ this.cachedStateJson = serialized;
210
+ return encodeWitPayloadUtf8(serialized);
211
+ }
212
+ catch {
213
+ return encodeWitPayloadUtf8('{}');
214
+ }
215
+ }
216
+ /** WIT `set-state(state: payload) -> result<_, actor-error>`. */
217
+ setState(stateJson) {
218
+ try {
219
+ const text = decodeWitPayloadUtf8(stateJson);
220
+ if (text.trim()) {
221
+ this.state = JSON.parse(text);
222
+ this.cachedStateJson = null; // Invalidate
223
+ }
224
+ }
225
+ catch {
226
+ throw new Error('ERROR:set_state failed');
227
+ }
228
+ }
229
+ capitalize(s) {
230
+ if (!s)
231
+ return "";
232
+ // Handle snake_case: "load_model" -> "Load_model" (preserve underscores, only capitalize first char)
233
+ // This matches handler naming: onLoad_model, onPredict_batch, etc.
234
+ // Only capitalize the first character, keep the rest as-is (preserves underscores and case)
235
+ return s.charAt(0).toUpperCase() + s.slice(1);
236
+ }
237
+ resolveDecoratedHandler(op, definition = getActorDefinition(this)) {
238
+ if (!definition)
239
+ return null;
240
+ return definition.handlers[op]?.methodName ?? null;
241
+ }
242
+ /**
243
+ * Serialize object to JSON string using fully iterative approach (zero recursion).
244
+ *
245
+ * jco componentize compiles JS to WASM (StarlingMonkey) with a tiny call stack.
246
+ * Native JSON.stringify recurses per-element and per-nesting-level, hitting
247
+ * stack limits with arrays of 2+ items. This iterative serializer uses a work
248
+ * stack instead of recursive function calls.
249
+ */
250
+ json(obj) {
251
+ return iterativeStringify(obj);
252
+ }
253
+ error(message) {
254
+ return "ERROR:" + message;
255
+ }
256
+ }
257
+ /**
258
+ * Base class for Workflow behavior actors (aligned with Rust Workflow trait and Python @workflow_actor).
259
+ *
260
+ * Implement run(), signal(), and query() for durable workflows (Temporal/Restate-style).
261
+ * Message routing: framework sends msgType "workflow_run" | "workflow_signal:name" | "workflow_query:name";
262
+ * PlexSpacesActor.handle() dispatches to these methods when present.
263
+ *
264
+ * Example:
265
+ * class OrderFulfillmentActor extends WorkflowActor<OrderState> {
266
+ * getDefaultState() { return { orderId: '', status: 'pending', steps: [] }; }
267
+ * run(payload: Record<string, unknown>) { ...; return { status: 'completed' }; }
268
+ * signal(name: string, data: Record<string, unknown>) { if (name === 'cancel') this.state.status = 'cancelled'; }
269
+ * query(name: string, params: Record<string, unknown>) { if (name === 'status') return this.state; return {}; }
270
+ * }
271
+ */
272
+ export class WorkflowActor extends PlexSpacesActor {
273
+ }
274
+ // ─── Fully Iterative JSON Serializer (ZERO recursion, ZERO method calls in loop) ───
275
+ // Character codes for escaping (pre-computed, avoids method calls)
276
+ const CHAR_QUOTE = 34; // "
277
+ const CHAR_BACKSLASH = 92; // \
278
+ const CHAR_NEWLINE = 10; // \n
279
+ const CHAR_CR = 13; // \r
280
+ const CHAR_TAB = 9; // \t
281
+ const CHAR_SPACE = 32; // space
282
+ // Escape table: for chars 0-31, pre-build the escape sequence
283
+ // This avoids ANY computation during string escaping
284
+ const ESCAPE_TABLE = [];
285
+ for (let i = 0; i < 128; i++) {
286
+ if (i === CHAR_QUOTE)
287
+ ESCAPE_TABLE[i] = '\\"';
288
+ else if (i === CHAR_BACKSLASH)
289
+ ESCAPE_TABLE[i] = '\\\\';
290
+ else if (i === CHAR_NEWLINE)
291
+ ESCAPE_TABLE[i] = '\\n';
292
+ else if (i === CHAR_CR)
293
+ ESCAPE_TABLE[i] = '\\r';
294
+ else if (i === CHAR_TAB)
295
+ ESCAPE_TABLE[i] = '\\t';
296
+ else if (i < CHAR_SPACE) {
297
+ // \u00XX
298
+ const h1 = (i >> 4) & 0xf;
299
+ const h0 = i & 0xf;
300
+ ESCAPE_TABLE[i] =
301
+ '\\u00' +
302
+ String.fromCharCode(h1 < 10 ? 48 + h1 : 87 + h1) +
303
+ String.fromCharCode(h0 < 10 ? 48 + h0 : 87 + h0);
304
+ }
305
+ else {
306
+ ESCAPE_TABLE[i] = ''; // No escape needed
307
+ }
308
+ }
309
+ /**
310
+ * Escape a string for JSON. Uses pre-computed escape table.
311
+ * No method calls in the hot loop (only charCodeAt and fromCharCode).
312
+ */
313
+ function escapeStr(s) {
314
+ let out = '"';
315
+ for (let i = 0, len = s.length; i < len; i++) {
316
+ const c = s.charCodeAt(i);
317
+ if (c < 128) {
318
+ const esc = ESCAPE_TABLE[c];
319
+ if (esc) {
320
+ out += esc;
321
+ }
322
+ else {
323
+ out += String.fromCharCode(c);
324
+ }
325
+ }
326
+ else {
327
+ // Non-ASCII: pass through (valid UTF-8 in JSON)
328
+ out += String.fromCharCode(c);
329
+ }
330
+ }
331
+ out += '"';
332
+ return out;
333
+ }
334
+ /**
335
+ * Fully iterative JSON serializer. ZERO recursion.
336
+ *
337
+ * Instead of recursive calls, we use an explicit work stack of "tokens" to emit.
338
+ * The stack contains either literal strings to output, or values to process.
339
+ * We process the stack in a single while loop with no function calls except
340
+ * escapeStr() for string values (which itself has no recursion).
341
+ *
342
+ * This design keeps WASM call stack depth constant regardless of data size.
343
+ */
344
+ // Work item types — using number tags (not strings) to avoid allocation
345
+ const TAG_VALUE = 0;
346
+ const TAG_LITERAL = 1;
347
+ /**
348
+ * Fully iterative JSON serializer with zero recursion.
349
+ * Uses a work stack instead of recursive function calls.
350
+ */
351
+ function iterativeStringify(root) {
352
+ // Stack of work items: [tag, payload]
353
+ // We use two parallel arrays instead of objects to minimize allocation
354
+ const stackTags = [];
355
+ const stackPayloads = [];
356
+ let sp = 0; // stack pointer
357
+ // Push root value
358
+ stackTags[0] = TAG_VALUE;
359
+ stackPayloads[0] = root;
360
+ sp = 1;
361
+ // Output fragments
362
+ const fragments = [];
363
+ let fragCount = 0;
364
+ while (sp > 0) {
365
+ // Pop
366
+ sp--;
367
+ const tag = stackTags[sp];
368
+ const payload = stackPayloads[sp];
369
+ // Help GC
370
+ stackPayloads[sp] = null;
371
+ if (tag === TAG_LITERAL) {
372
+ // Literal string — just emit
373
+ fragments[fragCount++] = payload;
374
+ continue;
375
+ }
376
+ // TAG_VALUE — process the value
377
+ if (payload === null || payload === undefined) {
378
+ fragments[fragCount++] = 'null';
379
+ continue;
380
+ }
381
+ const t = typeof payload;
382
+ if (t === 'string') {
383
+ fragments[fragCount++] = escapeStr(payload);
384
+ continue;
385
+ }
386
+ if (t === 'number') {
387
+ fragments[fragCount++] = '' + payload;
388
+ continue;
389
+ }
390
+ if (t === 'boolean') {
391
+ fragments[fragCount++] = payload ? 'true' : 'false';
392
+ continue;
393
+ }
394
+ if (t === 'function') {
395
+ fragments[fragCount++] = 'null';
396
+ continue;
397
+ }
398
+ // Object or Array
399
+ // Duck-type array detection (avoids Array.isArray which may recurse)
400
+ const obj = payload;
401
+ const len = obj['length'];
402
+ const isArr = typeof len === 'number' && len >= 0 && (len >>> 0) === len;
403
+ if (isArr) {
404
+ const arr = payload;
405
+ const arrLen = arr.length;
406
+ if (arrLen === 0) {
407
+ fragments[fragCount++] = '[]';
408
+ continue;
409
+ }
410
+ // Push tokens in REVERSE order (stack is LIFO):
411
+ // [ elem0 , elem1 , elem2 ]
412
+ // Push: ] elemN , elemN-1 , ... , elem0 [
413
+ stackTags[sp] = TAG_LITERAL;
414
+ stackPayloads[sp] = ']';
415
+ sp++;
416
+ for (let i = arrLen - 1; i >= 0; i--) {
417
+ stackTags[sp] = TAG_VALUE;
418
+ stackPayloads[sp] = arr[i];
419
+ sp++;
420
+ if (i > 0) {
421
+ stackTags[sp] = TAG_LITERAL;
422
+ stackPayloads[sp] = ',';
423
+ sp++;
424
+ }
425
+ }
426
+ stackTags[sp] = TAG_LITERAL;
427
+ stackPayloads[sp] = '[';
428
+ sp++;
429
+ continue;
430
+ }
431
+ // Plain object
432
+ // Use Object.getOwnPropertyNames() which is safer than for...in in StarlingMonkey
433
+ // This avoids recursion while still iterating all properties
434
+ let keys = [];
435
+ try {
436
+ // Object.getOwnPropertyNames() returns all own properties (non-enumerable too)
437
+ // This is safer than for...in which may trigger recursion in StarlingMonkey
438
+ const allProps = Object.getOwnPropertyNames(obj);
439
+ for (let i = 0; i < allProps.length; i++) {
440
+ const k = allProps[i];
441
+ // Skip function properties and undefined values
442
+ const v = obj[k];
443
+ if (v !== undefined && typeof v !== 'function') {
444
+ keys.push(k);
445
+ }
446
+ }
447
+ }
448
+ catch {
449
+ // If Object.getOwnPropertyNames fails, return empty object
450
+ fragments[fragCount++] = '{}';
451
+ continue;
452
+ }
453
+ if (keys.length === 0) {
454
+ fragments[fragCount++] = '{}';
455
+ continue;
456
+ }
457
+ // Push in reverse: } valN "keyN": , ... , val0 "key0": {
458
+ stackTags[sp] = TAG_LITERAL;
459
+ stackPayloads[sp] = '}';
460
+ sp++;
461
+ for (let i = keys.length - 1; i >= 0; i--) {
462
+ stackTags[sp] = TAG_VALUE;
463
+ stackPayloads[sp] = obj[keys[i]];
464
+ sp++;
465
+ stackTags[sp] = TAG_LITERAL;
466
+ stackPayloads[sp] = escapeStr(keys[i]) + ':';
467
+ sp++;
468
+ if (i > 0) {
469
+ stackTags[sp] = TAG_LITERAL;
470
+ stackPayloads[sp] = ',';
471
+ sp++;
472
+ }
473
+ }
474
+ stackTags[sp] = TAG_LITERAL;
475
+ stackPayloads[sp] = '{';
476
+ sp++;
477
+ }
478
+ // Join fragments without Array.join()
479
+ let result = '';
480
+ for (let i = 0; i < fragCount; i++) {
481
+ result += fragments[i];
482
+ }
483
+ return result;
484
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,171 @@
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
+ });