wstp-node 0.3.0

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/test.js ADDED
@@ -0,0 +1,660 @@
1
+ 'use strict';
2
+
3
+ // ── Test suite for wstp-backend v6 ─────────────────────────────────────────
4
+ // Covers: evaluation queue, streaming callbacks, sub() priority, abort
5
+ // behaviour, WstpReader side-channel, edge cases, Dialog[] subsession
6
+ // (dialogEval, exitDialog, isDialogOpen, onDialogBegin/End/Print).
7
+ // Run with: node test.js
8
+
9
+ const { WstpSession, WstpReader, setDiagHandler } = require('./build/Release/wstp.node');
10
+
11
+ const KERNEL_PATH = '/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel';
12
+
13
+ // Enable C++ diagnostic channel — writes timestamped messages to stderr.
14
+ // Suppress with: node test.js 2>/dev/null
15
+ setDiagHandler((msg) => {
16
+ const ts = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
17
+ process.stderr.write(`[diag ${ts}] ${msg}\n`);
18
+ });
19
+
20
+ // ── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
23
+
24
+ function assert(cond, msg) {
25
+ if (!cond) throw new Error(msg || 'assertion failed');
26
+ }
27
+
28
+ // pollUntil — re-checks condition every intervalMs until true or timeout.
29
+ function pollUntil(condition, timeoutMs = 3000, intervalMs = 50) {
30
+ return new Promise((resolve, reject) => {
31
+ const start = Date.now();
32
+ const id = setInterval(() => {
33
+ if (condition()) { clearInterval(id); resolve(); }
34
+ else if (Date.now() - start > timeoutMs) {
35
+ clearInterval(id);
36
+ reject(new Error('pollUntil timed out'));
37
+ }
38
+ }, intervalMs);
39
+ });
40
+ }
41
+
42
+ // Per-test timeout (ms). Any test that does not complete within this window
43
+ // is failed immediately with a "TIMED OUT" error. Prevents indefinite hangs.
44
+ const TEST_TIMEOUT_MS = 30_000;
45
+
46
+ // Hard suite-level watchdog: if the entire suite takes longer than this the
47
+ // process is force-killed. Covers cases where a test hangs AND the per-test
48
+ // timeout itself is somehow bypassed (e.g. a blocked native thread).
49
+ const SUITE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
50
+ const suiteWatchdog = setTimeout(() => {
51
+ console.error('\nFATAL: suite watchdog expired — process hung, force-exiting.');
52
+ process.exit(2);
53
+ }, SUITE_TIMEOUT_MS);
54
+ suiteWatchdog.unref(); // does not prevent normal exit
55
+
56
+ let passed = 0;
57
+ let failed = 0;
58
+
59
+ async function run(name, fn) {
60
+ // Race the test body against a per-test timeout.
61
+ const timeout = new Promise((_, reject) =>
62
+ setTimeout(() => reject(new Error(`TIMED OUT after ${TEST_TIMEOUT_MS} ms`)),
63
+ TEST_TIMEOUT_MS));
64
+ try {
65
+ await Promise.race([fn(), timeout]);
66
+ console.log(` ✓ ${name}`);
67
+ passed++;
68
+ } catch (e) {
69
+ console.error(` ✗ ${name}: ${e.message}`);
70
+ failed++;
71
+ process.exitCode = 1;
72
+ }
73
+ }
74
+
75
+ // ── Main ───────────────────────────────────────────────────────────────────
76
+
77
+ async function main() {
78
+ console.log('Opening session…');
79
+ const session = new WstpSession(KERNEL_PATH);
80
+ assert(session.isOpen, 'session did not open');
81
+ console.log('Session open.\n');
82
+
83
+ // ── 1. Basic queue serialisation ──────────────────────────────────────
84
+ await run('1. queue serialisation', async () => {
85
+ const [r1, r2, r3] = await Promise.all([
86
+ session.evaluate('1'),
87
+ session.evaluate('2'),
88
+ session.evaluate('3'),
89
+ ]);
90
+
91
+ assert(r1.result.value === 1,
92
+ `r1 wrong: ${JSON.stringify(r1.result)}`);
93
+ assert(r2.result.value === 2,
94
+ `r2 wrong: ${JSON.stringify(r2.result)}`);
95
+ assert(r3.result.value === 3,
96
+ `r3 wrong: ${JSON.stringify(r3.result)}`);
97
+
98
+ // Note: this WolframKernel only sends INPUTNAMEPKT (In[n]:=) once
99
+ // for the very first evaluation in a session. All subsequent evals
100
+ // return cellIndex=0. We verify the results are correct instead.
101
+ assert(r1.cellIndex >= 0, 'r1 cellIndex is a non-negative integer');
102
+ assert(r2.cellIndex >= 0, 'r2 cellIndex is a non-negative integer');
103
+ assert(r3.cellIndex >= 0, 'r3 cellIndex is a non-negative integer');
104
+ });
105
+
106
+ // ── 2. sub() priority over queued evaluate() ──────────────────────────
107
+ await run('2. sub() priority over queued evaluate()', async () => {
108
+ // Flood the queue with slow evaluations.
109
+ // p1 goes in-flight immediately; p2 and p3 are queued behind it.
110
+ const p1 = session.evaluate('Pause[0.5]; "first"');
111
+ const p2 = session.evaluate('Pause[0.5]; "second"');
112
+ const p3 = session.evaluate('Pause[0.5]; "third"');
113
+
114
+ // sub() is queued after p1 has already started — it must run before
115
+ // p2 and p3 (i.e. finish in ~500 ms, not ~1500 ms).
116
+ const subStart = Date.now();
117
+ const sv = await session.sub('42');
118
+ const subElapsed = Date.now() - subStart;
119
+
120
+ assert(
121
+ sv.type === 'integer' && sv.value === 42,
122
+ `sub result: ${JSON.stringify(sv)}`,
123
+ );
124
+ // p1 takes ~500 ms; sub itself is trivial — so total ≪ 1000 ms.
125
+ // We allow 1200 ms as a generous upper bound.
126
+ assert(subElapsed < 1200,
127
+ `sub elapsed ${subElapsed} ms — sub did not run with priority`);
128
+
129
+ await Promise.all([p1, p2, p3]);
130
+ });
131
+
132
+ // ── 3. Multiple sub() calls queue FIFO among themselves ───────────────
133
+ await run('3. sub() FIFO ordering', async () => {
134
+ // Keep session busy so subs queue up rather than start immediately.
135
+ const bg = session.evaluate('Pause[1]; "bg"');
136
+
137
+ const [sa, sb, sc] = await Promise.all([
138
+ session.sub('1'),
139
+ session.sub('2'),
140
+ session.sub('3'),
141
+ ]);
142
+
143
+ assert(sa.value === 1, `sa: ${JSON.stringify(sa)}`);
144
+ assert(sb.value === 2, `sb: ${JSON.stringify(sb)}`);
145
+ assert(sc.value === 3, `sc: ${JSON.stringify(sc)}`);
146
+
147
+ await bg;
148
+ });
149
+
150
+ // ── 4. Streaming onPrint fires before Promise resolves ────────────────
151
+ await run('4. streaming onPrint timing', async () => {
152
+ const lines = [];
153
+ const timestamps = [];
154
+ const evalStart = Date.now();
155
+
156
+ // Latch: the Promise should not resolve until all callbacks have fired
157
+ // (CompleteCtx guarantees this), but we add an explicit wait as a safety
158
+ // net in case TSFNs are still queued when the promise settles.
159
+ let deliveryResolve;
160
+ const allDelivered = new Promise(r => { deliveryResolve = r; });
161
+ let deliveredCount = 0;
162
+
163
+ const r = await session.evaluate(
164
+ 'Do[Print["line-" <> ToString[ii$$]]; Pause[0.2], {ii$$, 4}]',
165
+ {
166
+ onPrint: (line) => {
167
+ lines.push(line);
168
+ timestamps.push(Date.now() - evalStart);
169
+ if (++deliveredCount === 4) deliveryResolve();
170
+ },
171
+ },
172
+ );
173
+
174
+ // Wait up to 5 s for all 4 callbacks to actually fire.
175
+ await Promise.race([allDelivered, sleep(5000)]);
176
+
177
+ assert(lines.length === 4,
178
+ `expected 4 streamed lines, got ${lines.length} (r.print=${JSON.stringify(r.print)}, result=${JSON.stringify(r.result)}, msgs=${JSON.stringify(r.messages)})`);
179
+ assert(r.print.length === 4,
180
+ `expected 4 in result.print, got ${r.print.length}`);
181
+ assert(lines[0] === r.print[0],
182
+ `streaming[0]="${lines[0]}" vs batch[0]="${r.print[0]}"`);
183
+
184
+ // Lines arrive ~200 ms apart — confirm inter-arrival gap is plausible.
185
+ const gap = timestamps[1] - timestamps[0];
186
+ assert(gap > 50 && gap < 700,
187
+ `inter-line gap ${gap} ms — expected 50–700 ms`);
188
+ });
189
+
190
+ // ── 5. onMessage fires for kernel warnings ────────────────────────────
191
+ await run('5. streaming onMessage', async () => {
192
+ const msgs = [];
193
+ let msgResolve;
194
+ const msgDelivered = new Promise(r => { msgResolve = r; });
195
+ const r = await session.evaluate('1/0', {
196
+ onMessage: (m) => { msgs.push(m); msgResolve(); },
197
+ });
198
+
199
+ // Wait up to 5 s for the callback to actually fire.
200
+ await Promise.race([msgDelivered, sleep(5000)]);
201
+
202
+ assert(msgs.length > 0, `onMessage callback never fired (r.messages=${JSON.stringify(r.messages)}, r.result=${JSON.stringify(r.result)})`);
203
+ assert(r.messages.length > 0, `result.messages is empty (msgs=${JSON.stringify(msgs)})`);
204
+ assert(msgs[0] === r.messages[0],
205
+ `streaming[0]="${msgs[0]}" vs batch[0]="${r.messages[0]}"`);
206
+ assert(msgs[0].includes('::'),
207
+ `message missing :: separator: "${msgs[0]}"`);
208
+ });
209
+
210
+ // ── 6. abort() returns $Aborted, session stays alive ──────────────────
211
+ await run('6. abort + session survives', async () => {
212
+ // Do[Pause[0.1],{100}] has genuine yield points every 100ms so the
213
+ // kernel will react to WSAbortMessage quickly. Do[Null,{10^8}] is a
214
+ // tight computation loop that may not check for abort signals.
215
+ const p = session.evaluate('Do[Pause[0.1], {100}]');
216
+ await sleep(800); // give the eval time to be well-and-truly running
217
+ session.abort();
218
+ const r = await p;
219
+
220
+ assert(r.aborted === true, `aborted flag: ${r.aborted}`);
221
+ assert(r.result.value === '$Aborted',
222
+ `result: ${JSON.stringify(r.result)}`);
223
+
224
+ // session must still accept evaluations
225
+ const r2 = await session.evaluate('1 + 1');
226
+ assert(r2.result.value === 2,
227
+ `post-abort eval: ${JSON.stringify(r2.result)}`);
228
+ });
229
+
230
+ // ── 7. Abort drains queue — queued evals still run ────────────────────
231
+ await run('7. abort drains queue', async () => {
232
+ const p1 = session.evaluate('Do[Pause[0.1], {100}]'); // will be aborted
233
+ const p2 = session.evaluate('"after"'); // queued, must still run
234
+
235
+ await sleep(400);
236
+ session.abort();
237
+
238
+ const r1 = await p1;
239
+ assert(r1.aborted === true, `p1 not aborted: ${r1.aborted}`);
240
+
241
+ const r2 = await p2;
242
+ assert(
243
+ r2.result.type === 'string' && r2.result.value === 'after',
244
+ `p2 result: ${JSON.stringify(r2.result)}`,
245
+ );
246
+ });
247
+
248
+ // ── 8. sub() after abort — session healthy ────────────────────────────
249
+ await run('8. sub() after abort', async () => {
250
+ const p = session.evaluate('Do[Pause[0.1], {100}]');
251
+ await sleep(400);
252
+ session.abort();
253
+ await p;
254
+
255
+ // $ProcessID is a positive integer and reliably populated in WSTP mode.
256
+ const sv = await session.sub('$ProcessID');
257
+ assert(sv.type === 'integer', `type: ${sv.type}`);
258
+ assert(sv.value > 0, `ProcessID: ${sv.value}`);
259
+ });
260
+
261
+ // ── 9. cellIndex is present and results are correct ───────────────────
262
+ // Note: WolframKernel in -wstp mode only sends INPUTNAMEPKT for In[1]:=
263
+ // (the very first cell in the session). Subsequent evaluations return
264
+ // cellIndex=0. We verify that the first ever eval had cellIndex>=1 and
265
+ // that all results are structurally valid.
266
+ await run('9. cellIndex and result correctness', async () => {
267
+ const r1 = await session.evaluate('100');
268
+ const r2 = await session.evaluate('200');
269
+ const r3 = await session.evaluate('300');
270
+
271
+ assert(r1.result.value === 100, `r1: ${JSON.stringify(r1.result)}`);
272
+ assert(r2.result.value === 200, `r2: ${JSON.stringify(r2.result)}`);
273
+ assert(r3.result.value === 300, `r3: ${JSON.stringify(r3.result)}`);
274
+ assert(typeof r1.cellIndex === 'number', 'cellIndex is a number');
275
+ });
276
+
277
+ // ── 10. outputName and result.print / result.messages are arrays ───────
278
+ // Note: WolframKernel in -wstp mode does not send OUTPUTNAMEPKT (Out[n]=).
279
+ // outputName is always an empty string. We verify the structural shape
280
+ // of EvalResult — print and messages are always arrays, aborted is boolean.
281
+ await run('10. EvalResult structure is correct', async () => {
282
+ const r = await session.evaluate('testVar$$ = 7');
283
+ assert(typeof r.outputName === 'string', 'outputName is a string');
284
+ assert(Array.isArray(r.print), 'print is an array');
285
+ assert(Array.isArray(r.messages), 'messages is an array');
286
+ assert(typeof r.aborted === 'boolean', 'aborted is a boolean');
287
+ assert(r.result.value === 7, `result: ${JSON.stringify(r.result)}`);
288
+
289
+ const r2 = await session.evaluate('testVar$$^2');
290
+ assert(r2.result.value === 49, `squared: ${JSON.stringify(r2.result)}`);
291
+ });
292
+
293
+ // ── 11. WstpReader side-channel delivers real-time values ─────────────
294
+ // Pattern mirrors monitor_demo.js APPROACH 2:
295
+ // 1. Kernel creates link and returns its name.
296
+ // 2. WstpReader connects (JS main thread).
297
+ // 3. Background eval loop starts writing (NOT awaited).
298
+ // 4. readNext() completes WSActivate on first call, then reads data.
299
+ await run('11. WstpReader side-channel', async () => {
300
+ // Step 1: create a TCPIP link inside the kernel.
301
+ await session.evaluate('$sideLink$$ = LinkCreate[LinkProtocol -> "TCPIP"]');
302
+ const nameResult = await session.evaluate('$sideLink$$[[1]]');
303
+ const linkName = nameResult.result.value;
304
+ assert(
305
+ typeof linkName === 'string' && linkName.length > 0,
306
+ `bad linkName: ${JSON.stringify(nameResult.result)}`,
307
+ );
308
+
309
+ // Step 2: connect reader from JS — handshake is deferred to Execute().
310
+ const reader = new WstpReader(linkName, 'TCPIP');
311
+
312
+ // Step 3: start background writer (NOT awaited) so it runs concurrently
313
+ // with readNext(). The kernel enters the Do-loop and calls LinkWrite,
314
+ // which completes the deferred WSActivate handshake on the JS side.
315
+ // Pause AFTER each LinkWrite (not before) so the value is written
316
+ // immediately and the 300ms gap is before the NEXT write. A Pause[1]
317
+ // before LinkClose ensures the reader drains value 5 before the link
318
+ // close signal arrives — simultaneous data+close can cause WSTKEND.
319
+ const bgWrite = session.evaluate(
320
+ 'Do[LinkWrite[$sideLink$$, i]; Pause[0.3], {i, 1, 5}]; Pause[1]; LinkClose[$sideLink$$]; "done"',
321
+ );
322
+
323
+ // Step 4: read 5 values in real time.
324
+ // Use try/finally so reader.close() and bgWrite are always awaited
325
+ // even if readNext() rejects — prevents the session being poisoned by
326
+ // an orphaned in-flight bgWrite evaluation.
327
+ let received = [];
328
+ try {
329
+ for (let i = 0; i < 5; i++) {
330
+ const v = await reader.readNext();
331
+ received.push(v.value);
332
+ }
333
+ } finally {
334
+ reader.close();
335
+ // Always drain bgWrite so the session stays clean for subsequent tests.
336
+ try { await bgWrite; } catch (_) {}
337
+ }
338
+
339
+ assert(received.length === 5,
340
+ `expected 5 values, got ${received.length}`);
341
+ assert(
342
+ JSON.stringify(received) === JSON.stringify([1, 2, 3, 4, 5]),
343
+ `values: ${JSON.stringify(received)}`,
344
+ );
345
+ });
346
+
347
+ // ── 12. Large/deep expression — no stack overflow or process crash ───
348
+ // WolframKernel returns a deeply-nested Nest[f,x,600] expression.
349
+ // ReadExprRaw caps recursion at depth 512 and returns a WError, which
350
+ // EvaluateWorker::OnOK() converts to a Promise rejection.
351
+ // The test verifies the process survives: create a new evaluate() after
352
+ // the expected rejection to prove the session (and process) are intact.
353
+ await run('12. deep expression no crash', async () => {
354
+ let threw = false;
355
+ try {
356
+ await session.evaluate('Nest[f, x, 600]');
357
+ } catch (e) {
358
+ threw = true;
359
+ // Expected: 'expression too deep' from ReadExprRaw depth cap.
360
+ assert(e.message.includes('expression too deep') ||
361
+ e.message.includes('deep'),
362
+ `unexpected error: ${e.message}`);
363
+ }
364
+ assert(threw, 'expected a rejection for depth-capped expression');
365
+
366
+ // session must still accept evaluations after the rejection
367
+ const r2 = await session.evaluate('1 + 1');
368
+ assert(r2.result.value === 2, 'session alive after deep-expression rejection');
369
+ });
370
+
371
+ // ── 13. Syntax error produces message, not crash ──────────────────────
372
+ await run('13. syntax error no crash', async () => {
373
+ const r = await session.evaluate('1 +* 2');
374
+ assert(r !== null, 'null result from syntax error');
375
+ // Kernel sends a message for the parse error and returns $Failed / Null.
376
+ assert(
377
+ r.messages.length > 0 || r.result !== null,
378
+ 'no message and no result for syntax error',
379
+ );
380
+ });
381
+
382
+ // ── 14. close() is idempotent ─────────────────────────────────────────
383
+ await run('14. close() is idempotent', async () => {
384
+ const s2 = new WstpSession(KERNEL_PATH);
385
+ assert(s2.isOpen, 'fresh session not open');
386
+ s2.close();
387
+ assert(!s2.isOpen, 'isOpen true after first close');
388
+ s2.close(); // must not throw
389
+ assert(!s2.isOpen, 'isOpen true after second close');
390
+ });
391
+
392
+ // ── 15. Cooperative Dialog[] subsession ──────────────────────────────
393
+ // Evaluate an expression that opens Dialog[]. We watch isDialogOpen,
394
+ // check $DialogLevel inside the dialog, then close it with DialogReturn[].
395
+ //
396
+ // Note: within a dialog's EvaluatePacket context:
397
+ // - Return[] evaluates to Return[] (no enclosing structure to return from)
398
+ // - DialogReturn[] explicitly exits the dialog (correct exit mechanism)
399
+ // - Do-loop variables are NOT in scope (EvaluatePacket is a fresh evaluation)
400
+ await run('15. cooperative Dialog[] subsession', async () => {
401
+ let dialogOpened = false;
402
+ let dialogClosed = false;
403
+
404
+ const evalPromise = session.evaluate(
405
+ 'Dialog[]; "after-dialog"',
406
+ {
407
+ onDialogBegin: (_level) => { dialogOpened = true; },
408
+ onDialogEnd: (_level) => { dialogClosed = true; },
409
+ },
410
+ );
411
+
412
+ await pollUntil(() => session.isDialogOpen);
413
+ assert(session.isDialogOpen, 'isDialogOpen should be true inside Dialog[]');
414
+
415
+ // A simple expression should evaluate fine inside the dialog.
416
+ const two = await session.dialogEval('1 + 1');
417
+ assert(
418
+ two !== null && two.value === 2,
419
+ `expected 1+1 === 2 inside dialog, got ${JSON.stringify(two)}`,
420
+ );
421
+
422
+ // Close the dialog via exitDialog() — sends EnterTextPacket["Return[]"].
423
+ // (dialogEval('Return[]') does NOT close the dialog: Return[] is evaluated
424
+ // at the top level of EvaluatePacket where there is nothing to return from.)
425
+ await session.exitDialog();
426
+
427
+ // Wait for the outer evaluate() to finish.
428
+ const r = await evalPromise;
429
+ assert(
430
+ r.result !== null && r.result.value === 'after-dialog',
431
+ `expected "after-dialog", got ${JSON.stringify(r.result)}`,
432
+ );
433
+ assert(dialogOpened, 'onDialogBegin was never called');
434
+ assert(dialogClosed, 'onDialogEnd was never called');
435
+ assert(!session.isDialogOpen, 'isDialogOpen should be false after dialog closes');
436
+ });
437
+
438
+ // ── 16. dialogEval rejects when no dialog is open ────────────────────
439
+ await run('16. dialogEval rejects with no open dialog', async () => {
440
+ assert(!session.isDialogOpen, 'precondition: no dialog open');
441
+ let threw = false;
442
+ try {
443
+ await session.dialogEval('1 + 1');
444
+ } catch (e) {
445
+ threw = true;
446
+ assert(
447
+ e.message.includes('no dialog'),
448
+ `unexpected error text: ${e.message}`,
449
+ );
450
+ }
451
+ assert(threw, 'dialogEval should reject when no dialog is open');
452
+ });
453
+
454
+ // ── 17. Unhandled dialog does not corrupt the link ────────────────────
455
+ // Call Dialog[] from a plain evaluate(). No onDialogBegin callback is
456
+ // registered. We still use dialogEval('DialogReturn[]') to close it and
457
+ // confirm the outer Promise resolves correctly afterwards.
458
+ await run('17. unhandled dialog does not corrupt link', async () => {
459
+ const evalPromise = session.evaluate('Dialog[]; 42');
460
+
461
+ await pollUntil(() => session.isDialogOpen);
462
+ assert(session.isDialogOpen, 'dialog should have opened');
463
+ await session.exitDialog();
464
+
465
+ const r = await evalPromise;
466
+ assert(
467
+ r.result !== null && r.result.value === 42,
468
+ `expected 42, got ${JSON.stringify(r.result)}`,
469
+ );
470
+ });
471
+
472
+ // ── 19. exitDialog() with a return value ─────────────────────────────
473
+ // exitDialog('21') sends EnterTextPacket["Return[21]"]. The value 21
474
+ // becomes the value of Dialog[] in the outer expression, so x$$*2 == 42.
475
+ await run('19. exitDialog() with a return value', async () => {
476
+ const p = session.evaluate('x$$ = Dialog[]; x$$ * 2');
477
+ await pollUntil(() => session.isDialogOpen);
478
+ await session.exitDialog('21');
479
+ const r = await p;
480
+ assert(
481
+ r.result.value === 42,
482
+ `Dialog[] return value: ${JSON.stringify(r.result)}`,
483
+ );
484
+ });
485
+
486
+ // ── 20. dialogEval() sees outer variable state ────────────────────────
487
+ // Variables set before Dialog[] are in scope inside the subsession.
488
+ await run('20. dialogEval sees outer variable state', async () => {
489
+ const p = session.evaluate('myVar$$ = 123; Dialog[]; myVar$$');
490
+ await pollUntil(() => session.isDialogOpen);
491
+ const v = await session.dialogEval('myVar$$');
492
+ assert(
493
+ v !== null && v.value === 123,
494
+ `outer var inside dialog: ${JSON.stringify(v)}`,
495
+ );
496
+ await session.exitDialog();
497
+ await p;
498
+ });
499
+
500
+ // ── 21. dialogEval() can mutate kernel state ──────────────────────────
501
+ // Variables set inside the dialog persist after the dialog closes.
502
+ await run('21. dialogEval can mutate kernel state', async () => {
503
+ const p = session.evaluate('Dialog[]; mutated$$');
504
+ await pollUntil(() => session.isDialogOpen);
505
+ await session.dialogEval('mutated$$ = 777');
506
+ await session.exitDialog();
507
+ await p; // outer eval returns mutated$$
508
+ const check = await session.sub('mutated$$');
509
+ assert(
510
+ check.value === 777,
511
+ `mutation after dialog exit: ${JSON.stringify(check)}`,
512
+ );
513
+ });
514
+
515
+ // ── 22. Multiple dialogEval() calls are serviced FIFO ─────────────────
516
+ // Three concurrent dialogEval() calls queue up and resolve in order.
517
+ await run('22. multiple dialogEval calls are serviced FIFO', async () => {
518
+ const p = session.evaluate('Dialog[]');
519
+ await pollUntil(() => session.isDialogOpen);
520
+ const [a, b, c] = await Promise.all([
521
+ session.dialogEval('1'),
522
+ session.dialogEval('2'),
523
+ session.dialogEval('3'),
524
+ ]);
525
+ assert(
526
+ a.value === 1 && b.value === 2 && c.value === 3,
527
+ `FIFO: ${JSON.stringify([a, b, c])}`,
528
+ );
529
+ await session.exitDialog();
530
+ await p;
531
+ });
532
+
533
+ // ── 23. isDialogOpen transitions correctly ────────────────────────────
534
+ // false → true (on Dialog[]) → false (after exitDialog).
535
+ await run('23. isDialogOpen transitions correctly', async () => {
536
+ assert(!session.isDialogOpen, 'initially false');
537
+ const p = session.evaluate('Dialog[]');
538
+ await pollUntil(() => session.isDialogOpen);
539
+ assert(session.isDialogOpen, 'true while dialog open');
540
+ await session.exitDialog();
541
+ await p;
542
+ assert(!session.isDialogOpen, 'false after exit');
543
+ });
544
+
545
+ // ── 24. onDialogPrint fires for Print[] inside dialog ─────────────────
546
+ await run('24. onDialogPrint fires for Print[] inside dialog', async () => {
547
+ const dialogLines = [];
548
+ let dlgPrintResolve;
549
+ const dlgPrintDelivered = new Promise(r => { dlgPrintResolve = r; });
550
+ const p = session.evaluate('Dialog[]', {
551
+ onDialogPrint: (line) => { dialogLines.push(line); dlgPrintResolve(); },
552
+ });
553
+ await pollUntil(() => session.isDialogOpen);
554
+ await session.dialogEval('Print["hello-from-dialog"]');
555
+ // Wait up to 5 s for the callback to actually fire before exiting the dialog.
556
+ await Promise.race([dlgPrintDelivered, sleep(5000)]);
557
+ await session.exitDialog();
558
+ await p;
559
+ assert(
560
+ dialogLines.includes('hello-from-dialog'),
561
+ `onDialogPrint lines: ${JSON.stringify(dialogLines)}`,
562
+ );
563
+ });
564
+
565
+ // ── S. Streaming stability: 10 consecutive Print[] evals, same session ─
566
+ // Confirms that TSFN delivery is reliable across repeated evaluations
567
+ // without restarting the kernel. Mirrors real VSCode extension use.
568
+ await run('S. streaming stress (10× Print callback)', async () => {
569
+ for (let rep = 0; rep < 10; rep++) {
570
+ const lines = [];
571
+ let resolveAll;
572
+ const allFired = new Promise(r => { resolveAll = r; });
573
+ let count = 0;
574
+ const r = await session.evaluate(
575
+ 'Do[Print["s" <> ToString[jj$$]]; Pause[0.05], {jj$$, 4}]',
576
+ { onPrint: line => { lines.push(line); if (++count === 4) resolveAll(); } },
577
+ );
578
+ await Promise.race([allFired, sleep(5000)]);
579
+ assert(count === 4,
580
+ `rep ${rep + 1}/10: only ${count}/4 callbacks ` +
581
+ `(r.print=${JSON.stringify(r.print)})`);
582
+ }
583
+ });
584
+
585
+ // ── 26. exitDialog() rejects when no dialog is open ───────────────────
586
+ // Symmetric to test 16 which covers dialogEval().
587
+ await run('26. exitDialog() rejects with no open dialog', async () => {
588
+ assert(!session.isDialogOpen, 'precondition: no dialog open');
589
+ let threw = false;
590
+ try {
591
+ await session.exitDialog();
592
+ } catch (e) {
593
+ threw = true;
594
+ assert(
595
+ e.message.includes('no dialog'),
596
+ `unexpected error text: ${e.message}`,
597
+ );
598
+ }
599
+ assert(threw, 'exitDialog should reject when no dialog is open');
600
+ });
601
+
602
+ // ── 27. evaluate() queued during dialog runs after dialog closes ───────
603
+ // A plain evaluate() queued while the dialog inner loop is running must
604
+ // wait and then be dispatched normally after ENDDLGPKT.
605
+ await run('27. evaluate() queued during dialog runs after dialog closes', async () => {
606
+ const p1 = session.evaluate('Dialog[]');
607
+ await pollUntil(() => session.isDialogOpen);
608
+ // Queue a normal eval WHILE the dialog is open — it must wait.
609
+ const p2 = session.evaluate('"queued-during-dialog"');
610
+ await session.exitDialog();
611
+ await p1;
612
+ const r2 = await p2;
613
+ assert(
614
+ r2.result.type === 'string' && r2.result.value === 'queued-during-dialog',
615
+ `queued eval: ${JSON.stringify(r2.result)}`,
616
+ );
617
+ });
618
+
619
+ // ── 25. abort() while dialog is open ──────────────────────────────────
620
+ // Must run AFTER all other dialog tests — abort() sends WSAbortMessage
621
+ // which resets the WSTP link, leaving the session unusable for further
622
+ // evaluations. Tests 26 and 27 need a clean session, so test 25 runs last
623
+ // (just before test 18 which also corrupts the link via WSInterruptMessage).
624
+ await run('25. abort while dialog is open', async () => {
625
+ const p = session.evaluate('Dialog[]');
626
+ await pollUntil(() => session.isDialogOpen);
627
+ session.abort();
628
+ const r = await p;
629
+ assert(r.aborted === true, `aborted flag: ${r.aborted}`);
630
+ assert(!session.isDialogOpen, 'isDialogOpen false after abort');
631
+ // NOTE: session link is dead after abort() — no further evaluations.
632
+ });
633
+
634
+ // ── 18. interrupt() is callable (best-effort, no hard assertion) ──────
635
+ // interrupt() posts WSInterruptMessage. Without a Wolfram-side handler
636
+ // the kernel ignores it. This test just verifies the method exists and
637
+ // returns a boolean without throwing.
638
+ // NOTE: must run LAST — WSInterruptMessage stays buffered on the link and
639
+ // would abort the next Dialog[] evaluation, causing all dialog tests to hang.
640
+ await run('18. interrupt() does not throw', async () => {
641
+ const ok = session.interrupt();
642
+ assert(typeof ok === 'boolean', `interrupt() should return boolean, got ${typeof ok}`);
643
+ });
644
+
645
+ // ── Teardown ──────────────────────────────────────────────────────────
646
+ session.close();
647
+ assert(!session.isOpen, 'main session not closed');
648
+
649
+ console.log();
650
+ if (failed === 0) {
651
+ console.log(`All ${passed} tests passed.`);
652
+ } else {
653
+ console.log(`${passed} passed, ${failed} failed.`);
654
+ }
655
+ }
656
+
657
+ main().catch(e => {
658
+ console.error('Fatal:', e);
659
+ process.exit(1);
660
+ });