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.
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ // ── Interrupt → Dialog[] subsession diagnostic test ──────────────────────────
4
+ //
5
+ // Tests the specific flow used by the VS Code extension ⌥⇧↵ feature:
6
+ // 1. Install interrupt handler (via evaluate OR sub — we test both)
7
+ // 2. Start a long-running evaluate() in the background
8
+ // 3. Call session.interrupt() → kernel handler opens Dialog[]
9
+ // 4. Poll session.isDialogOpen — must flip to true within 3 s
10
+ // 5. Call session.dialogEval() inside the dialog
11
+ // 6. Call session.exitDialog() → main eval resumes
12
+ //
13
+ // Run:
14
+ // node test_interrupt_dialog.js
15
+ //
16
+ // With C++ diagnostics:
17
+ // DEBUG_WSTP=1 node test_interrupt_dialog.js 2>diag.txt
18
+ // grep -E "pkt=|interrupt|dialog|BEGINDLG|ENDDLG" diag.txt
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ const { WstpSession, setDiagHandler } = require('./build/Release/wstp.node');
22
+
23
+ const KERNEL_PATH = '/Applications/Wolfram 3.app/Contents/MacOS/WolframKernel';
24
+
25
+ setDiagHandler((msg) => {
26
+ const ts = new Date().toISOString().slice(11, 23);
27
+ process.stderr.write(`[diag ${ts}] ${msg}\n`);
28
+ });
29
+
30
+ function assert(cond, msg) {
31
+ if (!cond) throw new Error(msg || 'assertion failed');
32
+ }
33
+
34
+ function pollUntil(pred, timeoutMs = 3000, intervalMs = 50) {
35
+ return new Promise((resolve, reject) => {
36
+ const start = Date.now();
37
+ const tick = setInterval(() => {
38
+ if (pred()) { clearInterval(tick); resolve(); }
39
+ else if (Date.now() - start > timeoutMs) {
40
+ clearInterval(tick);
41
+ reject(new Error(`pollUntil timed out after ${timeoutMs} ms`));
42
+ }
43
+ }, intervalMs);
44
+ });
45
+ }
46
+
47
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
48
+
49
+ const TEST_TIMEOUT_MS = 20_000;
50
+ let passed = 0, failed = 0;
51
+
52
+ async function run(name, fn) {
53
+ process.stdout.write(` ${name} … `);
54
+ const timeout = new Promise((_, rej) =>
55
+ setTimeout(() => rej(new Error(`TIMED OUT after ${TEST_TIMEOUT_MS} ms`)), TEST_TIMEOUT_MS));
56
+ try {
57
+ await Promise.race([fn(), timeout]);
58
+ console.log('✓ PASS');
59
+ passed++;
60
+ } catch (e) {
61
+ console.log(`✗ FAIL: ${e.message}`);
62
+ failed++;
63
+ process.exitCode = 1;
64
+ }
65
+ }
66
+
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ async function runInterruptDialogTest(session, installVia, varName, testNum) {
70
+ // Install the interrupt handler.
71
+ // We test two paths:
72
+ // installVia = 'evaluate' → EnterExpressionPacket (interactive main loop)
73
+ // installVia = 'sub' → EvaluatePacket (batch, bypasses main loop)
74
+ const handlerExpr = 'Internal`AddHandler["Interrupt", Function[{}, Dialog[]]]';
75
+ if (installVia === 'evaluate') {
76
+ await session.evaluate(handlerExpr, {
77
+ // Pass all three Dialog callbacks so C++ drain loop can service BEGINDLGPKT
78
+ // even if the evaluate() call itself immediately triggers the dialog logic.
79
+ onDialogBegin: () => {},
80
+ onDialogEnd: () => {},
81
+ onDialogPrint: () => {},
82
+ });
83
+ } else {
84
+ await session.sub(handlerExpr);
85
+ }
86
+
87
+ // Start a long computation. It must have onDialogBegin/End callbacks — without
88
+ // them the C++ drain loop may not enter the BEGINDLGPKT handling path at all.
89
+ let dialogOpened = false;
90
+ let dialogClosed = false;
91
+ // Use a shorter loop so it can complete within the test timeout if needed.
92
+ // 30 iterations × 0.15 s = ~4.5 s after dialog exits.
93
+ const mainExpr = `Do[${varName} = k; Pause[0.15], {k, 1, 30}]; "main done ${testNum}"`;
94
+
95
+ const mainEval = session.evaluate(mainExpr, {
96
+ onDialogBegin: (_level) => { dialogOpened = true; },
97
+ onDialogEnd: (_level) => { dialogClosed = true; },
98
+ onDialogPrint: () => {},
99
+ });
100
+
101
+ // Wait for the loop to actually start running.
102
+ await sleep(300);
103
+
104
+ // Send WSInterruptMessage.
105
+ const sent = session.interrupt();
106
+ assert(sent === true, `interrupt() returned ${sent} — expected true (eval was in flight)`);
107
+ console.log(`\n [interrupt sent via ${installVia}]`);
108
+
109
+ // CRITICAL: isDialogOpen must flip to true within 3 s.
110
+ // If this assertion fires, the C++ backend is not processing BEGINDLGPKT.
111
+ try {
112
+ await pollUntil(() => session.isDialogOpen, 3000, 30);
113
+ } catch (_) {
114
+ // Cancel the stuck eval before throwing
115
+ mainEval.catch(() => {});
116
+ throw new Error(
117
+ `isDialogOpen never became true after interrupt() (handler via ${installVia}). ` +
118
+ `C++ drain loop may not handle BEGINDLGPKT, or WSInterruptMessage ` +
119
+ `was sent to the wrong link / with wrong message type.`
120
+ );
121
+ }
122
+
123
+ assert(session.isDialogOpen, 'isDialogOpen should be true inside Dialog[]');
124
+ assert(dialogOpened, 'onDialogBegin callback was never fired');
125
+
126
+ // Evaluate an expression inside the dialog — verifies dialogEval() works.
127
+ const val = await session.dialogEval(varName);
128
+ assert(
129
+ val !== null && typeof val.value === 'number' && val.value >= 1,
130
+ `dialogEval('${varName}') returned ${JSON.stringify(val)} — expected a number >= 1`
131
+ );
132
+ console.log(` [dialog open, ${varName} = ${val.value}]`);
133
+
134
+ // Close the dialog — main eval must resume.
135
+ await session.exitDialog();
136
+ assert(!session.isDialogOpen, 'isDialogOpen must be false after exitDialog()');
137
+ assert(dialogClosed, 'onDialogEnd callback was never fired');
138
+
139
+ // The main eval must now complete normally.
140
+ const r = await mainEval;
141
+ assert(
142
+ r && r.result && r.result.value === `main done ${testNum}`,
143
+ `main eval result after exitDialog(): ${JSON.stringify(r && r.result)}`
144
+ );
145
+ }
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+
149
+ async function main() {
150
+ console.log('\n=== Interrupt → Dialog[] subsession tests ===\n');
151
+
152
+ // Test A: handler installed via interactive evaluate() (EnterExpressionPacket)
153
+ // The README says this is the correct context for interrupt handlers.
154
+ // EXPECTED: PASS
155
+ {
156
+ console.log('Opening session A (interactive: true) …');
157
+ const sessionA = new WstpSession(KERNEL_PATH, { interactive: true });
158
+ assert(sessionA.isOpen, 'session A failed to open');
159
+
160
+ await run(
161
+ 'Test A — interrupt() → Dialog[] (handler via evaluate/interactive)',
162
+ () => runInterruptDialogTest(sessionA, 'evaluate', 'iA$$', 'A')
163
+ );
164
+
165
+ sessionA.close();
166
+ console.log(' Session A closed.\n');
167
+ }
168
+
169
+ // Brief pause between sessions to let kernel process cleanup.
170
+ await sleep(1000);
171
+
172
+ // Test B: handler installed via sub() (EvaluatePacket / batch context)
173
+ // This is the path init.wl currently uses (loaded via session.sub()).
174
+ // If this FAILS while test A passes: the extension must install the handler
175
+ // via session.evaluate() instead of relying on init.wl's sub() load.
176
+ // If this also PASSES: the batch vs interactive context does not matter.
177
+ {
178
+ console.log('Opening session B (interactive: true) …');
179
+ const sessionB = new WstpSession(KERNEL_PATH, { interactive: true });
180
+ assert(sessionB.isOpen, 'session B failed to open');
181
+
182
+ await run(
183
+ 'Test B — interrupt() → Dialog[] (handler via sub/batch)',
184
+ () => runInterruptDialogTest(sessionB, 'sub', 'iB$$', 'B')
185
+ );
186
+
187
+ sessionB.close();
188
+ console.log(' Session B closed.\n');
189
+ }
190
+
191
+ // ── Results ──
192
+ console.log('─'.repeat(50));
193
+ console.log(`Results: ${passed} passed, ${failed} failed`);
194
+ console.log();
195
+
196
+ if (failed > 0) {
197
+ console.log('DIAGNOSIS GUIDE');
198
+ console.log('───────────────');
199
+ console.log('Re-run with C++ diagnostics:');
200
+ console.log(' DEBUG_WSTP=1 node test_interrupt_dialog.js 2>diag.txt');
201
+ console.log(' grep -E "pkt=|interrupt|BEGINDLG|ENDDLG|isDialog" diag.txt');
202
+ console.log();
203
+ console.log('What to look for in diag.txt:');
204
+ console.log(' • After "interrupt sent", does "pkt=8" appear?');
205
+ console.log(' pkt=8 = BEGINDLGPKT — if absent, WSInterruptMessage is not');
206
+ console.log(' reaching the kernel, or the handler is not installed.');
207
+ console.log(' • If pkt=8 appears but isDialogOpen stays false: the C++ drain');
208
+ console.log(' loop receives BEGINDLGPKT but does not set isDialogOpen_ = true.');
209
+ console.log();
210
+ console.log('Key things to check in addon.cc:');
211
+ console.log(' 1. interrupt(): WSPutMessage(link_, WSInterruptMessage)');
212
+ console.log(' — must be WSInterruptMessage (=2), not WSAbortMessage (=4)');
213
+ console.log(' 2. Drain loop: case for BEGINDLGPKT (packet type 8)');
214
+ console.log(' — must set isDialogOpen_ = true and fire onDialogBegin TSFN');
215
+ console.log(' 3. Drain loop: inner dialog loop handling ENDDLGPKT (type 9)');
216
+ console.log(' — must set isDialogOpen_ = false and fire onDialogEnd TSFN');
217
+ }
218
+ }
219
+
220
+ main().catch(e => { console.error('Fatal:', e); process.exit(1); });